fix: eliminate cross-app consistency drift

This commit is contained in:
edde746
2026-07-13 23:13:53 +02:00
parent ec249d2ed4
commit d5f3e581df
142 changed files with 4751 additions and 1075 deletions
@@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
void main() {
test('user-facing and accessibility labels are populated in every locale', () async {
for (final locale in AppLocale.values) {
final translations = await locale.build();
final labels = <String, String>{
'hotkeys.pressToRecord': translations.hotkeys.pressToRecord,
'hotkeys.recordingShortcut': translations.hotkeys.recordingShortcut,
'accessibility.expandText': translations.accessibility.expandText,
'accessibility.collapseText': translations.accessibility.collapseText,
'watchTogether.openSessionControls': translations.watchTogether.openSessionControls,
'watchTogether.copySessionCode': translations.watchTogether.copySessionCode,
'services.deviceCode.copyCode': translations.services.deviceCode.copyCode,
'services.oauthProxy.copyUrl': translations.services.oauthProxy.copyUrl,
};
for (final MapEntry(key: key, value: value) in labels.entries) {
expect(value.trim(), isNotEmpty, reason: '${locale.languageCode}: $key');
}
}
});
}
+45 -12
View File
@@ -121,13 +121,18 @@ class _FakeAggregationService extends DataAggregationService {
}
class _FakeClient implements MediaServerClient {
final String serverIdValue;
final String serverNameValue;
final List<String> fetchedItemIds = [];
MediaItem? itemResult;
@override
ServerId get serverId => ServerId('server_1');
_FakeClient({this.serverIdValue = 'server_1', this.serverNameValue = 'Server'});
@override
String? get serverName => 'Server';
ServerId get serverId => ServerId(serverIdValue);
@override
String? get serverName => serverNameValue;
@override
MediaBackend get backend => MediaBackend.plex;
@@ -136,7 +141,10 @@ class _FakeClient implements MediaServerClient {
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
Future<MediaItem?> fetchItem(String id, {bool useCache = true}) async => itemResult;
Future<MediaItem?> fetchItem(String id, {bool useCache = true}) async {
fetchedItemIds.add(id);
return itemResult;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
@@ -146,6 +154,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _FakeClient client;
late MultiServerManager manager;
late _FakeAggregationService aggregation;
late MultiServerProvider multiServer;
late HiddenLibrariesProvider hiddenLibraries;
@@ -162,7 +171,7 @@ void main() {
shelfSyncs = [];
client = _FakeClient();
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
manager = MultiServerManager()..debugRegisterClientForTesting(client);
aggregation = _FakeAggregationService(manager);
multiServer = MultiServerProvider(manager, aggregation);
hiddenLibraries = HiddenLibrariesProvider();
@@ -588,18 +597,42 @@ void main() {
expect(provider.hubs, isEmpty);
});
test('updateItem refetches one item and swaps it in place', () async {
aggregation.onDeckResult = () => [_item('ep-1')];
test('updateItem qualifies same bare ids by source server without fan-out', () async {
final serverOneItem = _item('shared-id', serverId: 'server_1').copyWith(title: 'Server One Original');
final serverTwoItem = _item('shared-id', serverId: 'server_2').copyWith(title: 'Server Two Original');
final serverTwoClient = _FakeClient(serverIdValue: 'server_2', serverNameValue: 'Server Two');
manager.debugRegisterClientForTesting(serverTwoClient);
aggregation.onDeckResult = () => [serverOneItem, serverTwoItem];
aggregation.hubsResult = () => [
_hub('hub-1', items: [_item('movie-1')]),
_hub('hub-1', serverId: 'server_1', items: [serverOneItem]),
_hub('hub-2', serverId: 'server_2', items: [serverTwoItem]),
];
await provider.load();
final onDeckCallsBefore = aggregation.onDeckCalls;
final hubCallsBefore = aggregation.hubCalls;
final source = provider.hubs
.expand((hub) => hub.items)
.singleWhere((item) => item.globalKey == serverTwoItem.globalKey);
final updated = serverTwoItem.copyWith(title: 'Server Two Refreshed');
serverTwoClient.itemResult = updated;
client.itemResult = _item('movie-1').copyWith(title: 'Updated Title');
await provider.updateItem('movie-1');
await provider.updateItem(source);
expect(provider.hubs.single.items.single.title, 'Updated Title');
expect(provider.onDeck.single.id, 'ep-1');
// The old bare-id aggregation path fetched every server. The source item
// must instead select one client and leave both full-list fetch surfaces
// untouched.
expect(client.fetchedItemIds, isEmpty);
expect(serverTwoClient.fetchedItemIds, ['shared-id']);
expect(aggregation.onDeckCalls, onDeckCallsBefore);
expect(aggregation.hubCalls, hubCallsBefore);
final onDeckByKey = {for (final item in provider.onDeck) item.globalKey: item};
final hubItemsByKey = {for (final item in provider.hubs.expand((hub) => hub.items)) item.globalKey: item};
expect(onDeckByKey[serverOneItem.globalKey], same(serverOneItem));
expect(onDeckByKey[serverTwoItem.globalKey], same(updated));
expect(hubItemsByKey[serverOneItem.globalKey], same(serverOneItem));
expect(hubItemsByKey[serverTwoItem.globalKey], same(updated));
});
test('syncToOnlineServers reloads for mid-session connects only', () async {
+52 -5
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart' as material;
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/providers/theme_provider.dart';
import 'package:plezy/screens/settings/settings_utils.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_service.dart' as settings;
@@ -9,6 +11,7 @@ import '../test_helpers/prefs.dart';
void main() {
setUp(resetSharedPreferencesForTest);
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
group('ThemeProvider', () {
test('exposes a non-null themeMode immediately and flips after init', () async {
@@ -103,29 +106,41 @@ void main() {
p.dispose();
});
test('themeModeDisplayName and themeModeIcon match the active mode', () async {
test('themeModeLabel and themeModeIcon match the active mode', () async {
final p = ThemeProvider();
await Future.delayed(Duration.zero);
await p.setThemeMode(settings.ThemeMode.light);
expect(p.themeModeDisplayName, 'Light');
expect(themeModeLabel(p.themeMode), t.settings.lightTheme);
expect(p.themeModeIcon, Symbols.light_mode_rounded);
await p.setThemeMode(settings.ThemeMode.dark);
expect(p.themeModeDisplayName, 'Dark');
expect(themeModeLabel(p.themeMode), t.settings.darkTheme);
expect(p.themeModeIcon, Symbols.dark_mode_rounded);
await p.setThemeMode(settings.ThemeMode.oled);
expect(p.themeModeDisplayName, 'OLED');
expect(themeModeLabel(p.themeMode), t.settings.oledTheme);
expect(p.themeModeIcon, Symbols.contrast_rounded);
await p.setThemeMode(settings.ThemeMode.system);
expect(p.themeModeDisplayName, 'System');
expect(themeModeLabel(p.themeMode), t.settings.systemTheme);
expect(p.themeModeIcon, Symbols.brightness_auto_rounded);
p.dispose();
});
test('themeModeLabel follows a non-English locale for every mode', () async {
await LocaleSettings.setLocale(AppLocale.de);
try {
expect(themeModeLabel(settings.ThemeMode.system), 'System');
expect(themeModeLabel(settings.ThemeMode.light), 'Hell');
expect(themeModeLabel(settings.ThemeMode.dark), 'Dunkel');
expect(themeModeLabel(settings.ThemeMode.oled), 'OLED');
} finally {
LocaleSettings.setLocaleSync(AppLocale.en);
}
});
test('reload re-reads after external mutation', () async {
final p = ThemeProvider();
await Future.delayed(Duration.zero);
@@ -167,4 +182,36 @@ void main() {
await p.reload();
});
});
testWidgets('regex dialog localizes its field label and validation error', (tester) async {
await LocaleSettings.setLocale(AppLocale.de);
addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
await tester.pumpWidget(
material.MaterialApp(
home: material.Scaffold(
body: material.Builder(
builder: (context) => material.TextButton(
onPressed: () => showRegexInputDialog(
context: context,
title: 'Muster',
currentValue: '^intro',
defaultValue: '',
onSave: (_) async {},
),
child: const material.Text('Öffnen'),
),
),
),
),
);
await tester.tap(find.text('Öffnen'));
await tester.pumpAndSettle();
expect(find.text('Regulärer Ausdruck'), findsOneWidget);
await tester.enterText(find.byType(material.TextField), '[');
await tester.pump();
expect(find.text('Ungültiger regulärer Ausdruck'), findsOneWidget);
});
}
@@ -226,17 +226,22 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Hosted request sheet'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('Hosted request sheet'), findsNothing);
// Android TV also dispatches a route-level back for the same remote press,
// sometimes after the sheet's close animation has completed.
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
// Android TV can dispatch route Back for the same remote press. Deliver
// that duplicate in the same key sequence, before the coordinator's
// one-frame ownership marker is cleared.
await tester.binding.handlePopRoute();
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('Hosted request sheet'), findsNothing);
expect(find.byType(CatalogItemDetailScreen), findsOneWidget);
await expectLater(sheetResult, completion(isNull));
// A later, independent system Back still pops the catalog route.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(CatalogItemDetailScreen), findsNothing);
});
testWidgets('recommendation posters use compact grid-equivalent TV sizing', (tester) async {
@@ -1,12 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_cast_member.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/catalog_item_detail_screen.dart';
import 'package:plezy/screens/catalog_search_screen.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/catalog_library_matcher.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/widgets/app_menu.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
@@ -21,6 +32,9 @@ class _FakeSearchSource implements CatalogSource {
@override
String get displayName => 'Trakt';
@override
bool get supportsWatchlist => false;
@override
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
queries.add(query);
@@ -33,6 +47,15 @@ class _FakeSearchSource implements CatalogSource {
];
}
@override
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [];
@override
void dispose() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -98,6 +121,111 @@ void main() {
expect(_state(tester).searchResults.single.title, 'result: abcd');
expect(find.text(t.explore.searchFailed), findsNothing);
});
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
testWidgets('catalog result menu uses the adaptive sheet and dispatches on ${platform.name}', (tester) async {
final source = _FakeSearchSource();
await _pumpMenuSearch(tester, source, platform: platform);
await _searchForMenuResult(tester);
final card = find.byType(MediaCard);
expect(card, findsOneWidget);
tester.state<MediaCardState>(card).showContextMenu();
await tester.pumpAndSettle();
expect(_adaptiveMenuSheet(), findsOneWidget);
expect(_appMenuList(), findsOneWidget);
await tester.tap(find.text(t.mediaMenu.viewDetails));
await tester.pumpAndSettle();
expect(_adaptiveMenuSheet(), findsNothing);
expect(find.byType(CatalogItemDetailScreen), findsOneWidget);
});
}
testWidgets('catalog result menu uses an anchored desktop popup and dispatches its result', (tester) async {
final source = _FakeSearchSource();
await _pumpMenuSearch(tester, source, platform: TargetPlatform.macOS);
await _searchForMenuResult(tester);
final card = find.byType(MediaCard);
expect(card, findsOneWidget);
final cardRect = tester.getRect(card);
tester.state<MediaCardState>(card).showContextMenu();
await tester.pumpAndSettle();
expect(_adaptiveMenuSheet(), findsNothing);
expect(_appMenuList(), findsOneWidget);
final menuRect = tester.getRect(_appMenuList());
expect(menuRect.left, closeTo(cardRect.left, 0.01));
expect(menuRect.top, closeTo(cardRect.bottom + 4, 0.01));
await tester.tap(find.text(t.mediaMenu.viewDetails));
await tester.pumpAndSettle();
expect(_appMenuList(), findsNothing);
expect(find.byType(CatalogItemDetailScreen), findsOneWidget);
});
}
dynamic _state(WidgetTester tester) => tester.state<State<CatalogSearchScreen>>(find.byType(CatalogSearchScreen));
class _FakeCatalogSourcesProvider extends CatalogSourcesProvider {
_FakeCatalogSourcesProvider(this.source);
final CatalogSource source;
@override
List<CatalogSource> get connectedSources => [source];
}
class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher {
_FakeCatalogLibraryMatcher(super.multiServer);
@override
Future<List<MediaItem>> match(CatalogItem item) async => const [];
}
Future<void> _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {required TargetPlatform platform}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = platform == TargetPlatform.iOS || platform == TargetPlatform.android
? const Size(390, 844)
: const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final sources = _FakeCatalogSourcesProvider(source);
final manager = MultiServerManager();
final multiServer = MultiServerProvider(manager, DataAggregationService(manager));
final matcher = _FakeCatalogLibraryMatcher(multiServer);
addTearDown(manager.dispose);
addTearDown(multiServer.dispose);
addTearDown(sources.dispose);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<CatalogLibraryMatcher>.value(value: matcher),
ChangeNotifierProvider<CatalogSourcesProvider>.value(value: sources),
],
child: MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: platform),
home: CatalogSearchScreen(source: source),
),
),
),
);
}
Future<void> _searchForMenuResult(WidgetTester tester) async {
await tester.enterText(find.byType(TextField), 'menu');
await tester.pump(const Duration(milliseconds: 600));
await tester.pumpAndSettle();
expect(find.text('result: menu'), findsOneWidget);
}
Finder _adaptiveMenuSheet() => find.byWidgetPredicate((widget) => widget is AppMenuSheet<Object?>);
Finder _appMenuList() => find.byWidgetPredicate((widget) => widget is AppMenuList<Object?>);
@@ -1,11 +1,20 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.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_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/navigation/main_screen_scope.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/downloads/downloads_screen.dart';
@@ -16,9 +25,14 @@ import 'package:plezy/services/jellyfin_api_cache.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/services/music/music_playback_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/io_fakes.dart';
class _FakeConnectionRegistry extends ConnectionRegistry {
_FakeConnectionRegistry(super.db);
@@ -30,23 +44,33 @@ class _FakeConnectionRegistry extends ConnectionRegistry {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final originalPathProvider = PathProviderPlatform.instance;
late AppDatabase db;
late DownloadProvider downloadProvider;
late MultiServerProvider multiServerProvider;
late MultiServerManager serverManager;
late DownloadManagerService downloadManager;
late DownloadStorageService storageService;
late Directory temporaryDirectory;
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
temporaryDirectory = await Directory.systemTemp.createTemp('downloads_screen_focus_test_');
PathProviderPlatform.instance = FakePathProvider(temporaryDirectory);
await SettingsService.getInstance();
storageService = DownloadStorageService.instance;
await storageService.initialize(SettingsService.instance);
db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
final downloadManager = DownloadManagerService(
downloadManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
storageService: storageService,
clientResolver: (serverId, {clientScopeId}) => null,
);
downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
@@ -58,29 +82,21 @@ void main() {
tearDown(() async {
downloadProvider.dispose();
downloadManager.dispose();
multiServerProvider.dispose();
await db.close();
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = originalPathProvider;
if (await temporaryDirectory.exists()) {
await temporaryDirectory.delete(recursive: true);
}
});
testWidgets('right from the last tab (Music) focuses and opens Sync Rules action', (tester) async {
final screenKey = GlobalKey<DownloadsScreenState>();
await tester.pumpWidget(
InputModeTracker(
child: MultiProvider(
providers: [
Provider<ConnectionRegistry>.value(value: _FakeConnectionRegistry(db)),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.macOS),
home: DownloadsScreen(key: screenKey),
),
),
),
);
await tester.pumpAndSettle();
await _pumpScreen(tester, db, downloadProvider, multiServerProvider, screenKey: screenKey);
final state = screenKey.currentState!;
state.tabController.index = 3;
@@ -94,4 +110,238 @@ void main() {
expect(find.text('Sync rules'), findsOneWidget);
});
testWidgets('grid view renders downloaded offline cards in a bounded sliver grid', (tester) async {
await SettingsService.instance.write(SettingsService.viewMode, ViewMode.grid);
_seedDownloadedMovies(downloadProvider, 4);
final screenKey = GlobalKey<DownloadsScreenState>();
var sidebarCalls = 0;
await _pumpScreen(
tester,
db,
downloadProvider,
multiServerProvider,
screenKey: screenKey,
onSidebar: () => sidebarCalls++,
);
screenKey.currentState!.tabController.index = 2;
await tester.pumpAndSettle();
final scrollView = _activeDownloadsScrollView();
expect(scrollView, findsOneWidget);
expect(find.descendant(of: scrollView, matching: find.byType(SliverGrid)), findsOneWidget);
expect(find.descendant(of: scrollView, matching: find.byType(SliverList)), findsNothing);
_expectBoundedSingleScrollViewport(tester, scrollView);
final cards = tester
.widgetList<FocusableMediaCard>(find.descendant(of: scrollView, matching: find.byType(FocusableMediaCard)))
.toList();
expect(cards, hasLength(4));
expect(cards.every((card) => card.isOffline), isTrue);
expect(cards.every((card) => !card.disableScale), isTrue);
final first = _movieCard(cards, 0);
final second = _movieCard(cards, 1);
expect(first.focusNode, isNotNull);
expect(first.onBack, isNotNull);
expect(first.onNavigateLeft, isNotNull);
expect(second.onNavigateLeft, isNull);
first.onNavigateLeft!();
expect(sidebarCalls, 1);
first.onBack!();
await tester.pump();
expect(screenKey.currentState!.getTabChipFocusNode(2).hasFocus, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('list view renders downloaded offline cards as bounded sliver rows', (tester) async {
await SettingsService.instance.write(SettingsService.viewMode, ViewMode.list);
_seedDownloadedMovies(downloadProvider, 3);
final screenKey = GlobalKey<DownloadsScreenState>();
var sidebarCalls = 0;
await _pumpScreen(
tester,
db,
downloadProvider,
multiServerProvider,
screenKey: screenKey,
onSidebar: () => sidebarCalls++,
);
screenKey.currentState!.tabController.index = 2;
await tester.pumpAndSettle();
final scrollView = _activeDownloadsScrollView();
expect(scrollView, findsOneWidget);
expect(find.descendant(of: scrollView, matching: find.byType(SliverList)), findsOneWidget);
expect(find.descendant(of: scrollView, matching: find.byType(SliverGrid)), findsNothing);
_expectBoundedSingleScrollViewport(tester, scrollView);
final cards = tester
.widgetList<FocusableMediaCard>(find.descendant(of: scrollView, matching: find.byType(FocusableMediaCard)))
.toList();
expect(cards, hasLength(3));
expect(cards.every((card) => card.isOffline), isTrue);
expect(cards.every((card) => card.disableScale), isTrue);
final first = _movieCard(cards, 0);
final second = _movieCard(cards, 1);
expect(first.focusNode, isNotNull);
expect(first.onBack, isNotNull);
expect(first.onNavigateLeft, isNotNull);
expect(second.onNavigateLeft, isNotNull);
second.onNavigateLeft!();
expect(sidebarCalls, 1);
first.onBack!();
await tester.pump();
expect(screenKey.currentState!.getTabChipFocusNode(2).hasFocus, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('downloaded album header bounds local artwork decoding on both axes', (tester) async {
const serverId = 'downloads-layout-server';
const thumbPath = '/downloads-layout/album/thumb';
final artworkPath = downloadProvider.getArtworkLocalPath(ServerId(serverId), thumbPath);
expect(artworkPath, isNotNull);
final artworkFile = File(artworkPath!);
artworkFile.parent.createSync(recursive: true);
artworkFile.writeAsBytesSync(
base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='),
);
addTearDown(() {
if (artworkFile.existsSync()) artworkFile.deleteSync();
});
final track = testMediaItem(
id: 'downloaded-track',
kind: MediaKind.track,
title: 'Downloaded Track',
parentId: 'downloaded-album',
parentTitle: 'Downloaded Album',
grandparentId: 'downloaded-artist',
grandparentTitle: 'Downloaded Artist',
parentIndex: 1,
index: 1,
serverId: serverId,
);
final album = testMediaItem(
id: 'downloaded-album',
kind: MediaKind.album,
title: 'Downloaded Album',
parentId: 'downloaded-artist',
parentTitle: 'Downloaded Artist',
thumbPath: thumbPath,
serverId: serverId,
);
downloadProvider.debugSeedState(
downloads: {
'$serverId:${track.id}': DownloadProgress(globalKey: '$serverId:${track.id}', status: DownloadStatus.completed),
},
metadata: {'$serverId:${track.id}': track, '$serverId:${album.id}': album},
);
final screenKey = GlobalKey<DownloadsScreenState>();
await _pumpScreen(tester, db, downloadProvider, multiServerProvider, screenKey: screenKey);
screenKey.currentState!.tabController.index = 3;
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Downloaded Album'), findsOneWidget);
final headerImages = tester
.widgetList<Image>(find.byType(Image))
.where((image) => image.width == 48 && image.height == 48)
.toList();
expect(headerImages, hasLength(1));
final provider = headerImages.single.image;
expect(provider, isA<ResizeImage>());
final resized = provider as ResizeImage;
expect(resized.width, 120);
expect(resized.height, 180);
expect(resized.policy, ResizeImagePolicy.fit);
expect(resized.imageProvider, isA<FileImage>());
expect((resized.imageProvider as FileImage).file.path, artworkFile.path);
expect(tester.takeException(), isNull);
});
}
MediaItem _downloadedMovie(int index) => testMediaItem(
id: 'movie-$index',
kind: MediaKind.movie,
title: 'Downloaded Movie $index',
serverId: 'downloads-layout-server',
);
void _seedDownloadedMovies(DownloadProvider provider, int count) {
final movies = List.generate(count, _downloadedMovie);
provider.debugSeedState(
downloads: {
for (final movie in movies)
movie.globalKey: DownloadProgress(globalKey: movie.globalKey, status: DownloadStatus.completed),
},
metadata: {for (final movie in movies) movie.globalKey: movie},
);
}
FocusableMediaCard _movieCard(List<FocusableMediaCard> cards, int index) =>
cards.singleWhere((card) => (card.item as MediaItem).id == 'movie-$index');
Finder _activeDownloadsScrollView() =>
find.descendant(of: find.byType(TabBarView), matching: find.byType(CustomScrollView));
void _expectBoundedSingleScrollViewport(WidgetTester tester, Finder scrollView) {
final renderBox = tester.renderObject<RenderBox>(scrollView);
expect(renderBox.constraints, const BoxConstraints(minWidth: 1200, maxWidth: 1200, minHeight: 744, maxHeight: 744));
expect(tester.getSize(scrollView), const Size(1200, 744));
final scrollable = find.descendant(of: scrollView, matching: find.byType(Scrollable));
expect(scrollable, findsOneWidget);
final position = tester.state<ScrollableState>(scrollable).position;
expect(position.hasContentDimensions, isTrue);
expect(position.viewportDimension, 744);
expect(position.minScrollExtent, 0);
expect(position.maxScrollExtent, 0);
expect(position.pixels, 0);
}
Future<void> _pumpScreen(
WidgetTester tester,
AppDatabase db,
DownloadProvider downloadProvider,
MultiServerProvider multiServerProvider, {
required GlobalKey<DownloadsScreenState> screenKey,
VoidCallback? onSidebar,
}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1200, 800);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
InputModeTracker(
child: MultiProvider(
providers: [
Provider<ConnectionRegistry>.value(value: _FakeConnectionRegistry(db)),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<MusicPlaybackService>(create: (_) => StubMusicPlaybackService()),
],
child: MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.macOS),
home: MainScreenFocusScope(
focusSidebar: onSidebar ?? () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: DownloadsScreen(key: screenKey),
),
),
),
),
);
await tester.pump();
}
@@ -1,20 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/mixins/grid_focus_node_mixin.dart';
import 'package:plezy/screens/focusable_detail_screen_mixin.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
TvDetectionService.debugSetAppleTVOverride(false);
BackKeyCoordinator.clear();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyCoordinator.clear();
});
testWidgets('detail scaffold scrolls to top on iOS top safe-area tap', (tester) async {
@@ -68,12 +73,105 @@ void main() {
expect(detailRoute, isNotNull);
expect(detailRoute!.popGestureEnabled, isTrue);
});
testWidgets('collection surface hosts adaptive sheets and system Back closes the sheet before the route', (
tester,
) async {
await _pushDetailSurface(tester, surfaceName: 'collection', hasActions: true);
await tester.tap(find.text('Open collection sheet'));
await tester.pumpAndSettle();
final sheet = find.text('collection sheet');
expect(sheet, findsOneWidget);
expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(find.byType(_TestDetailScreen), findsOneWidget);
});
testWidgets('actor surface D-pad Back closes its hosted sheet, then actor can pop with no actions', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await _pushDetailSurface(tester, surfaceName: 'actor', hasActions: false);
await tester.tap(find.text('Open actor sheet'));
await tester.pumpAndSettle();
final sheet = find.text('actor sheet');
expect(sheet, findsOneWidget);
expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget);
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
// Android TV can dispatch route Back for the same remote press. Dispatch
// that duplicate before pumping so the coordinator deterministically
// associates it with this key-down instead of a later, independent Back.
await tester.binding.handlePopRoute();
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(find.byType(_TestDetailScreen), findsOneWidget);
// After the key press has settled, a new system Back is independent. With
// no actor actions to focus, it must pop the route immediately.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(_TestDetailScreen), findsNothing);
expect(find.text('Open actor detail'), findsOneWidget);
});
testWidgets('collection surface system Back focuses its action bar before popping the route', (tester) async {
await _pushDetailSurface(tester, surfaceName: 'collection', hasActions: true);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(_TestDetailScreen), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(_TestDetailScreen), findsNothing);
expect(find.text('Open collection detail'), findsOneWidget);
});
}
Future<void> _pushDetailSurface(WidgetTester tester, {required String surfaceName, required bool hasActions}) async {
await tester.pumpWidget(
MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.android),
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => _TestDetailScreen(surfaceName: surfaceName, hasActions: hasActions),
),
),
child: Text('Open $surfaceName detail'),
),
),
),
),
);
await tester.tap(find.text('Open $surfaceName detail'));
await tester.pumpAndSettle();
expect(find.byType(_TestDetailScreen), findsOneWidget);
}
class _TestDetailScreen extends StatefulWidget {
final VoidCallback? onTopTargetTap;
final String surfaceName;
final bool hasActions;
const _TestDetailScreen({this.onTopTargetTap});
const _TestDetailScreen({this.onTopTargetTap, this.surfaceName = 'detail', this.hasActions = false});
@override
State<_TestDetailScreen> createState() => _TestDetailScreenState();
@@ -85,7 +183,9 @@ class _TestDetailScreenState extends State<_TestDetailScreen>
bool get hasItems => true;
@override
List<FocusableAction> getAppBarActions() => const [];
List<FocusableAction> getAppBarActions() => widget.hasActions
? [FocusableAction(icon: Icons.more_horiz, tooltip: 'Collection actions', onPressed: () {})]
: const [];
@override
void dispose() {
@@ -97,11 +197,27 @@ class _TestDetailScreenState extends State<_TestDetailScreen>
Widget build(BuildContext context) {
return buildDetailScaffold(
slivers: [
SliverAppBar(title: Text('${widget.surfaceName} detail'), actions: buildFocusableAppBarActions()),
SliverToBoxAdapter(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTopTargetTap,
child: const SizedBox(height: 80, child: Text('Top target')),
child: Column(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTopTargetTap,
child: const SizedBox(height: 80, child: Text('Top target')),
),
Builder(
builder: (sheetContext) => TextButton(
onPressed: () async {
await OverlaySheetController.showAdaptive<void>(
sheetContext,
builder: (_) => SizedBox(height: 120, child: Center(child: Text('${widget.surfaceName} sheet'))),
);
},
child: Text('Open ${widget.surfaceName} sheet'),
),
),
],
),
),
SliverList.builder(
@@ -1,8 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/media/library_first_character.dart';
import 'package:plezy/screens/libraries/alpha_jump_bar.dart';
import 'package:plezy/utils/platform_detector.dart';
const _backKeys = [
_BackKeyCase('Escape', LogicalKeyboardKey.escape, PhysicalKeyboardKey.escape),
_BackKeyCase('GoBack', LogicalKeyboardKey.goBack, PhysicalKeyboardKey.escape),
_BackKeyCase('BrowserBack', LogicalKeyboardKey.browserBack, PhysicalKeyboardKey.browserBack),
_BackKeyCase('GameButtonB', LogicalKeyboardKey.gameButtonB, PhysicalKeyboardKey.enter),
];
void main() {
const alphabetCharacters = [
@@ -35,6 +45,91 @@ void main() {
LibraryFirstCharacter(key: 'Z', title: 'Z', size: 1),
];
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
});
for (final backKey in _backKeys) {
testWidgets('${backKey.label} runs back once on key up and consumes the normal press', (tester) async {
TvDetectionService.debugSetAppleTVOverride(false);
final harness = await _pumpBackHarness(tester);
final downHandled = _sendKeyDown(tester, backKey);
expect(downHandled, isTrue);
expect(harness.jumpBarBacks, 0);
expect(harness.ancestorEvents, 0);
final repeatHandled = _sendKeyRepeat(tester, backKey);
expect(repeatHandled, isTrue);
expect(harness.jumpBarBacks, 0);
expect(harness.ancestorEvents, 0);
final upHandled = _sendKeyUp(tester, backKey);
expect(upHandled, isTrue);
expect(harness.jumpBarBacks, 1);
expect(harness.ancestorEvents, 0);
expect(harness.ancestorBacks, 0);
await tester.pump();
harness.moveFocusOnBack = true;
final movingDownHandled = _sendKeyDown(tester, backKey);
final movingRepeatHandled = _sendKeyRepeat(tester, backKey);
final movingUpHandled = _sendKeyUp(tester, backKey);
expect(movingDownHandled, isTrue);
expect(movingRepeatHandled, isTrue);
expect(movingUpHandled, isTrue);
expect(harness.jumpBarBacks, 2);
expect(harness.ancestorEvents, 0);
expect(harness.ancestorBacks, 0);
await tester.pump();
expect(harness.nextFocusNode.hasFocus, isTrue);
});
testWidgets('${backKey.label} runs back once on Apple TV key down and consumes key up', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final harness = await _pumpBackHarness(tester);
final downHandled = _sendKeyDown(tester, backKey);
expect(downHandled, isTrue);
expect(harness.jumpBarBacks, 1);
expect(harness.ancestorEvents, 0);
final repeatHandled = _sendKeyRepeat(tester, backKey);
expect(repeatHandled, isTrue);
expect(harness.jumpBarBacks, 1);
expect(harness.ancestorEvents, 0);
final upHandled = _sendKeyUp(tester, backKey);
expect(upHandled, isTrue);
expect(harness.jumpBarBacks, 1);
expect(harness.ancestorEvents, 0);
expect(harness.ancestorBacks, 0);
await tester.pump();
harness.moveFocusOnBack = true;
final movingDownHandled = _sendKeyDown(tester, backKey);
expect(movingDownHandled, isTrue);
expect(harness.jumpBarBacks, 2);
expect(harness.ancestorEvents, 0);
await tester.pump();
expect(harness.nextFocusNode.hasFocus, isTrue);
final movingRepeatHandled = _sendKeyRepeat(tester, backKey);
final movingUpHandled = _sendKeyUp(tester, backKey);
expect(movingRepeatHandled, isTrue);
expect(movingUpHandled, isTrue);
expect(harness.jumpBarBacks, 2);
expect(harness.ancestorEvents, 2);
expect(harness.ancestorBacks, 0);
});
}
testWidgets('keeps the full alphabet visible in a short TV-height bar', (tester) async {
await tester.pumpWidget(
MaterialApp(
@@ -121,3 +216,114 @@ void main() {
expect(jumpedTo, 2);
});
}
class _BackKeyCase {
final String label;
final LogicalKeyboardKey logicalKey;
final PhysicalKeyboardKey physicalKey;
const _BackKeyCase(this.label, this.logicalKey, this.physicalKey);
}
class _BackHarness {
final jumpBarFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back');
final nextFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_destination');
final ancestorFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_ancestor');
var jumpBarBacks = 0;
var ancestorEvents = 0;
var ancestorBacks = 0;
var moveFocusOnBack = false;
void dispose() {
jumpBarFocusNode.dispose();
nextFocusNode.dispose();
ancestorFocusNode.dispose();
}
}
Future<_BackHarness> _pumpBackHarness(WidgetTester tester) async {
final harness = _BackHarness();
addTearDown(harness.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Focus(
focusNode: harness.ancestorFocusNode,
onKeyEvent: (_, event) {
harness.ancestorEvents++;
return handleBackKeyAction(event, () => harness.ancestorBacks++);
},
child: Column(
children: [
SizedBox(
height: 300,
child: AlphaJumpBar(
firstCharacters: const [
LibraryFirstCharacter(key: 'A', title: 'A', size: 3),
LibraryFirstCharacter(key: 'B', title: 'B', size: 4),
LibraryFirstCharacter(key: 'C', title: 'C', size: 2),
],
currentLetter: 'B',
focusNode: harness.jumpBarFocusNode,
onJump: (_) {},
onBack: () {
harness.jumpBarBacks++;
if (harness.moveFocusOnBack) {
harness.nextFocusNode.requestFocus();
}
},
),
),
Focus(focusNode: harness.nextFocusNode, child: const SizedBox(width: 1, height: 1)),
],
),
),
),
),
);
harness.jumpBarFocusNode.requestFocus();
await tester.pump();
expect(harness.jumpBarFocusNode.hasFocus, isTrue);
return harness;
}
bool _sendKeyDown(WidgetTester _, _BackKeyCase backKey) {
return _dispatchKeyEvent(
KeyDownEvent(physicalKey: backKey.physicalKey, logicalKey: backKey.logicalKey, timeStamp: Duration.zero),
);
}
bool _sendKeyRepeat(WidgetTester _, _BackKeyCase backKey) {
return _dispatchKeyEvent(
KeyRepeatEvent(
physicalKey: backKey.physicalKey,
logicalKey: backKey.logicalKey,
timeStamp: const Duration(milliseconds: 1),
),
);
}
bool _sendKeyUp(WidgetTester _, _BackKeyCase backKey) {
return _dispatchKeyEvent(
KeyUpEvent(
physicalKey: backKey.physicalKey,
logicalKey: backKey.logicalKey,
timeStamp: const Duration(milliseconds: 2),
),
);
}
bool _dispatchKeyEvent(KeyEvent event) {
final handled = HardwareKeyboard.instance.handleKeyEvent(event);
FocusNode? node = FocusManager.instance.primaryFocus;
while (node != null) {
final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
if (result == KeyEventResult.handled) return true;
if (result == KeyEventResult.skipRemainingHandlers) return handled;
node = node.parent;
}
return handled;
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_button.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/libraries/state_messages.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => LocaleSettings.setLocaleSync(AppLocale.en));
tearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('StateMessageWidget forwards action focus behavior to FocusableButton', (tester) async {
await _pump(
tester,
StateMessageWidget(
message: 'Nothing here',
actionLabel: 'Reload',
onAction: () {},
actionAutofocus: true,
actionUseBackgroundFocus: true,
),
);
final button = tester.widget<FocusableButton>(find.byType(FocusableButton));
expect(button.autofocus, isTrue);
expect(button.useBackgroundFocus, isTrue);
final focusedNode = FocusManager.instance.primaryFocus;
expect(focusedNode, isNotNull);
final focusedWidget = find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode == focusedNode);
expect(focusedWidget, findsOneWidget);
expect(find.ancestor(of: focusedWidget, matching: find.byType(FocusableButton)), findsOneWidget);
});
testWidgets('ErrorStateWidget retry supports D-pad and pointer activation', (tester) async {
var retries = 0;
await _pump(
tester,
ErrorStateWidget(
message: 'Could not load',
retryLabel: 'Try again',
onRetry: () => retries++,
actionAutofocus: true,
actionUseBackgroundFocus: true,
),
);
final button = tester.widget<FocusableButton>(find.byType(FocusableButton));
expect(button.autofocus, isTrue);
expect(button.useBackgroundFocus, isTrue);
await tester.tap(find.widgetWithText(FilledButton, 'Try again'));
await tester.pump();
expect(retries, 1);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(retries, 2);
});
group('ErrorStateWidget localized defaults', () {
setUp(() async {
await LocaleSettings.setLocale(AppLocale.es);
});
testWidgets('uses the localized default retry label', (tester) async {
await _pump(tester, ErrorStateWidget(message: 'No se pudo cargar', onRetry: () {}));
expect(find.text('Reintentar'), findsOneWidget);
expect(find.text('Retry'), findsNothing);
});
});
}
Future<void> _pump(WidgetTester tester, Widget child) async {
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: MaterialApp(home: Scaffold(body: child)),
),
),
);
await tester.pumpAndSettle();
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/models/media_subscription.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/livetv/live_tv_show_schedule_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => initializeDateFormatting('en'));
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
});
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('record action is D-pad reachable and invokes the recording flow once', (tester) async {
final dvr = _FakeLiveTvDvr();
final client = _FakeMediaServerClient(
liveTv: _FakeLiveTvSupport(
dvr: dvr,
programs: [_program(guid: 'plex://episode/recordable')],
),
supportsDvr: true,
);
final provider = _providerFor(client);
addTearDown(provider.dispose);
await _pumpScreen(tester, provider);
expect(find.byType(FocusableActionBar), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'FocusableWrapper');
expect(dvr.subscriptionTemplateCalls, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(dvr.subscriptionTemplateCalls, 1);
expect(dvr.requestedGuids, ['plex://episode/recordable']);
});
testWidgets('non-recordable schedules do not add a dead app-bar focus target', (tester) async {
final unsupportedClient = _FakeMediaServerClient(
liveTv: _FakeLiveTvSupport(
dvr: _FakeLiveTvDvr(),
programs: [_program(guid: 'plex://episode/unsupported')],
),
supportsDvr: false,
);
final unsupportedProvider = _providerFor(unsupportedClient);
addTearDown(unsupportedProvider.dispose);
await _pumpScreen(tester, unsupportedProvider);
await _expectNoRecordFocusTarget(tester);
final missingGuidClient = _FakeMediaServerClient(
liveTv: _FakeLiveTvSupport(
dvr: _FakeLiveTvDvr(),
programs: [
_program(),
_program(guid: ''),
],
),
supportsDvr: true,
);
final missingGuidProvider = _providerFor(missingGuidClient);
addTearDown(missingGuidProvider.dispose);
await _pumpScreen(tester, missingGuidProvider);
await _expectNoRecordFocusTarget(tester);
});
}
Future<void> _expectNoRecordFocusTarget(WidgetTester tester) async {
expect(find.byType(FocusableActionBar), findsNothing);
final scheduleFocus = FocusManager.instance.primaryFocus;
expect(scheduleFocus?.debugLabel, 'FocusableWrapper');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus, same(scheduleFocus));
}
LiveTvProgram _program({String? guid}) => LiveTvProgram(
guid: guid,
title: 'Pilot',
grandparentTitle: 'Example Show',
beginsAt: 1_900_000_000,
endsAt: 1_900_003_600,
);
MultiServerProvider _providerFor(MediaServerClient client) {
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
return MultiServerProvider(manager, DataAggregationService(manager));
}
Future<void> _pumpScreen(WidgetTester tester, MultiServerProvider provider) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: const LiveTvShowScheduleScreen(showTitle: 'Example Show', serverId: 'server-a', channels: []),
),
),
),
),
);
await tester.pumpAndSettle();
}
class _FakeMediaServerClient implements MediaServerClient {
@override
final LiveTvSupport liveTv;
final bool supportsDvr;
_FakeMediaServerClient({required this.liveTv, required this.supportsDvr});
@override
ServerId get serverId => ServerId('server-a');
@override
String? get serverName => 'Test server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities(liveTv: true, liveTvDvr: supportsDvr);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeLiveTvSupport implements LiveTvSupport {
@override
final LiveTvDvrSupport? dvr;
final List<LiveTvProgram> programs;
_FakeLiveTvSupport({required this.dvr, required this.programs});
@override
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to}) async => programs;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeLiveTvDvr implements LiveTvDvrSupport {
int subscriptionTemplateCalls = 0;
final List<String> requestedGuids = [];
@override
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid) async {
subscriptionTemplateCalls++;
requestedGuids.add(guid);
return const [];
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/watch_state_store.dart';
import 'package:plezy/screens/playlist/playlist_item_card.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import 'package:plezy/widgets/media_progress_bar.dart';
import 'package:plezy/widgets/unwatched_count_badge.dart';
import 'package:plezy/widgets/watched_indicator.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('watched leaf uses the shared compact checkmark and remains tappable', (tester) async {
final store = WatchStateStore();
addTearDown(store.dispose);
var tapCount = 0;
final item = testMediaItem(
id: 'watched-movie',
kind: MediaKind.movie,
title: 'Watched movie',
serverId: 'server-1',
durationMs: 120000,
viewOffsetMs: 0,
viewCount: 1,
);
await _pumpCard(tester, store: store, item: item, onTap: () => tapCount++);
final indicator = find.byType(WatchedIndicator);
expect(indicator, findsOneWidget);
expect(tester.widget<WatchedIndicator>(indicator).size, WatchedIndicatorSize.compact);
expect(find.descendant(of: indicator, matching: find.byIcon(Symbols.check_rounded)), findsOneWidget);
expect(find.byType(MediaProgressBar), findsNothing);
await tester.tap(find.text('Watched movie'));
await tester.pump();
expect(tapCount, 1);
});
testWidgets('active resume renders exactly one shared progress bar', (tester) async {
final store = WatchStateStore();
addTearDown(store.dispose);
final item = testMediaItem(
id: 'in-progress-movie',
kind: MediaKind.movie,
title: 'In progress',
serverId: 'server-1',
durationMs: 120000,
viewOffsetMs: 30000,
);
await _pumpCard(tester, store: store, item: item);
expect(find.byType(WatchedIndicator), findsOneWidget);
expect(find.byType(MediaProgressBar), findsOneWidget);
expect(find.byIcon(Symbols.check_rounded), findsNothing);
});
testWidgets('zero, terminal, and past-end offsets do not render a progress bar', (tester) async {
final store = WatchStateStore();
addTearDown(store.dispose);
for (final offset in <int>[0, 120000, 130000]) {
final item = testMediaItem(
id: 'movie-$offset',
kind: MediaKind.movie,
title: 'Movie $offset',
serverId: 'server-1',
durationMs: 120000,
viewOffsetMs: offset,
);
await _pumpCard(tester, store: store, item: item);
expect(find.byType(MediaProgressBar), findsNothing, reason: 'offset $offset is not active progress');
}
});
testWidgets('show and season containers render their remaining-count badge', (tester) async {
final store = WatchStateStore();
addTearDown(store.dispose);
final show = testMediaItem(
id: 'show',
kind: MediaKind.show,
title: 'Show',
serverId: 'server-1',
leafCount: 12,
viewedLeafCount: 5,
);
final season = testMediaItem(
id: 'season',
kind: MediaKind.season,
title: 'Season',
serverId: 'server-1',
leafCount: 8,
viewedLeafCount: 2,
);
await _pumpCard(tester, store: store, item: show);
expect(find.byType(UnwatchedCountBadge), findsOneWidget);
expect(find.text('7'), findsOneWidget);
await _pumpCard(tester, store: store, item: season);
expect(find.byType(UnwatchedCountBadge), findsOneWidget);
expect(find.text('6'), findsOneWidget);
});
testWidgets('watch-state patches update the overlay without mutating the source item', (tester) async {
final store = WatchStateStore();
addTearDown(store.dispose);
final item = testMediaItem(
id: 'patched-episode',
kind: MediaKind.episode,
title: 'Patched episode',
serverId: 'server-1',
durationMs: 120000,
);
await _pumpCard(tester, store: store, item: item);
expect(find.byType(MediaProgressBar), findsNothing);
expect(find.byIcon(Symbols.check_rounded), findsNothing);
WatchStateNotifier().notifyProgress(item: item, viewOffset: 30000, duration: 120000);
await tester.pump();
await tester.pump();
expect(find.byType(MediaProgressBar), findsOneWidget);
expect(find.byIcon(Symbols.check_rounded), findsNothing);
expect(item.viewOffsetMs, isNull);
expect(item.isWatched, isFalse);
WatchStateNotifier().notifyWatched(item: item);
await tester.pump();
await tester.pump();
expect(find.byType(MediaProgressBar), findsNothing);
expect(find.byIcon(Symbols.check_rounded), findsOneWidget);
expect(item.viewOffsetMs, isNull);
expect(item.isWatched, isFalse);
});
}
Future<void> _pumpCard(
WidgetTester tester, {
required WatchStateStore store,
required MediaItem item,
VoidCallback? onTap,
}) async {
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<WatchStateStore>.value(
value: store,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 500,
child: PlaylistItemCard(item: item, index: 0, onRemove: () {}, onTap: onTap, canReorder: false),
),
),
),
),
),
);
await tester.pump();
}
+243 -7
View File
@@ -1,9 +1,11 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -14,6 +16,7 @@ import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/playlist/playlist_detail_screen.dart';
import 'package:plezy/screens/playlist/playlist_item_card.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
@@ -23,13 +26,16 @@ import 'package:plezy/services/playlist_items_loader.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import '../test_helpers/paged_fakes.dart';
import 'package:plezy/utils/media_navigation_helper.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/paged_fakes.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -38,6 +44,13 @@ void main() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(false);
BackKeyCoordinator.clear();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyCoordinator.clear();
});
testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async {
@@ -165,6 +178,221 @@ void main() {
expect(playlistRoute, isNotNull);
expect(playlistRoute!.popGestureEnabled, isTrue);
});
testWidgets('playlist adaptive sheet stays in-tree and system or D-pad Back closes it before the route', (
tester,
) async {
TvDetectionService.debugSetAppleTVOverride(true);
final harness = await _createHarness(_mediaItems(2));
await _pushPlaylistRoute(tester, harness);
final sheetResult = OverlaySheetController.showAdaptive<void>(
tester.element(find.byType(PlaylistItemCard).first),
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('Playlist sheet'))),
);
await tester.pumpAndSettle();
final sheet = find.text('Playlist sheet');
expect(sheet, findsOneWidget);
expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
// A route-level Back can accompany the same TV remote press before the
// coordinator's one-frame marker is cleared.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
await expectLater(sheetResult, completion(isNull));
final systemSheetResult = OverlaySheetController.showAdaptive<void>(
tester.element(find.byType(PlaylistItemCard).first),
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('Playlist system sheet'))),
);
await tester.pumpAndSettle();
expect(find.text('Playlist system sheet'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Playlist system sheet'), findsNothing);
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
await expectLater(systemSheetResult, completion(isNull));
});
testWidgets('playlist move mode restores order and cancels before system Back can exit the route', (tester) async {
final harness = await _createHarness(_mediaItems(2));
await _pushPlaylistRoute(tester, harness);
final listFocus = find.byWidgetPredicate(
(widget) => widget is Focus && widget.focusNode?.debugLabel == 'playlist_list',
);
expect(listFocus, findsOneWidget);
tester.widget<Focus>(listFocus).focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(tester.widget<PlaylistItemCard>(find.byType(PlaylistItemCard).first).isMoving, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0']);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1']);
expect(tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => !card.isMoving), isTrue);
});
testWidgets('successful playlist deletion pops true from the detail route', (tester) async {
final harness = await _createHarness(_mediaItems(1), deleteResult: true);
bool? routeResult;
await tester.pumpWidget(
harness.wrap(
Builder(
builder: (context) => TextButton(
onPressed: () async {
routeResult = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => const PlaylistDetailScreen(playlist: _playlist)));
},
child: const Text('Open playlist'),
),
),
),
);
await tester.tap(find.text('Open playlist'));
await tester.pumpAndSettle();
await _choosePlaylistDeletion(tester, confirm: true);
expect(routeResult, isTrue);
expect(harness.client.deleteCalls, 1);
expect(find.byType(PlaylistDetailScreen), findsNothing);
});
testWidgets('media navigation maps a successful playlist deletion to listRefreshNeeded', (tester) async {
final harness = await _createHarness(_mediaItems(1), deleteResult: true);
MediaNavigationResult? navigationResult;
await tester.pumpWidget(
harness.wrap(
Builder(
builder: (context) => TextButton(
onPressed: () async {
navigationResult = await navigateToMediaItem(context, _playlist);
},
child: const Text('Navigate to playlist'),
),
),
),
);
await tester.tap(find.text('Navigate to playlist'));
await tester.pumpAndSettle();
await _choosePlaylistDeletion(tester, confirm: true);
expect(navigationResult, MediaNavigationResult.listRefreshNeeded);
expect(harness.client.deleteCalls, 1);
});
testWidgets('playlist card reload callback runs exactly once after successful deletion', (tester) async {
final harness = await _createHarness(_mediaItems(1), deleteResult: true);
var reloads = 0;
await tester.pumpWidget(
harness.wrap(
Scaffold(
body: SizedBox(
width: 640,
child: MediaCard(item: _playlist, forceListMode: true, onListRefresh: () => reloads++),
),
),
),
);
await tester.pumpAndSettle();
tester.state<MediaCardState>(find.byType(MediaCard)).handleTap();
await tester.pumpAndSettle();
await _choosePlaylistDeletion(tester, confirm: true);
expect(reloads, 1);
expect(harness.client.deleteCalls, 1);
await tester.pump();
expect(reloads, 1);
});
testWidgets('cancelled and failed deletion plus a non-delete pop never reload the playlist card', (tester) async {
final harness = await _createHarness(_mediaItems(1), deleteResult: false);
var reloads = 0;
await tester.pumpWidget(
harness.wrap(
Scaffold(
body: SizedBox(
width: 640,
child: MediaCard(item: _playlist, forceListMode: true, onListRefresh: () => reloads++),
),
),
),
);
await tester.pumpAndSettle();
tester.state<MediaCardState>(find.byType(MediaCard)).handleTap();
await tester.pumpAndSettle();
await _choosePlaylistDeletion(tester, confirm: false);
expect(reloads, 0);
expect(harness.client.deleteCalls, 0);
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
await _choosePlaylistDeletion(tester, confirm: true);
expect(reloads, 0);
expect(harness.client.deleteCalls, 1);
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
Navigator.of(tester.element(find.byType(PlaylistDetailScreen))).pop();
await tester.pumpAndSettle();
expect(find.byType(PlaylistDetailScreen), findsNothing);
expect(find.byType(MediaCard), findsOneWidget);
expect(reloads, 0);
});
}
Future<void> _pushPlaylistRoute(WidgetTester tester, _PlaylistHarness harness) async {
await tester.pumpWidget(
harness.wrap(
Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const PlaylistDetailScreen(playlist: _playlist))),
child: const Text('Open playlist'),
),
),
),
);
await tester.tap(find.text('Open playlist'));
await tester.pumpAndSettle();
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
}
Future<void> _choosePlaylistDeletion(WidgetTester tester, {required bool confirm}) async {
await tester.tap(find.byTooltip(t.playlists.delete));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
await tester.tap(find.text(confirm ? t.common.delete : t.common.cancel));
await tester.pumpAndSettle();
}
List<String> _visiblePlaylistItemIds(WidgetTester tester) {
return tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).map((card) => card.item.id).toList();
}
const _playlist = MediaPlaylist(
@@ -190,7 +418,7 @@ List<MediaItem> _mediaItems(int count) {
);
}
Future<_PlaylistHarness> _createHarness(List<MediaItem> items, {int? failOnceAt}) async {
Future<_PlaylistHarness> _createHarness(List<MediaItem> items, {int? failOnceAt, bool deleteResult = false}) async {
await SettingsService.getInstance();
final db = AppDatabase.forTesting(NativeDatabase.memory());
@@ -206,7 +434,7 @@ Future<_PlaylistHarness> _createHarness(List<MediaItem> items, {int? failOnceAt}
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await downloadProvider.ensureInitialized();
final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt);
final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
@@ -246,11 +474,13 @@ class _PlaylistHarness {
class _PagedPlaylistClient implements MediaServerClient {
final List<MediaItem> items;
final int? failOnceAt;
final bool deleteResult;
final List<int?> requestedStarts = [];
final List<int?> requestedSizes = [];
int deleteCalls = 0;
bool _hasFailed = false;
_PagedPlaylistClient(this.items, {this.failOnceAt});
_PagedPlaylistClient(this.items, {this.failOnceAt, this.deleteResult = false});
@override
ServerId get serverId => ServerId('server_1');
@@ -277,6 +507,12 @@ class _PagedPlaylistClient implements MediaServerClient {
return fakeLibraryPage(items, start: start, size: size);
}
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
deleteCalls++;
return deleteResult;
}
@override
void close() {}
@@ -1,6 +1,7 @@
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/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/profile/add_local_profile_screen.dart';
@@ -69,7 +70,7 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
await tester.tap(find.byIcon(Icons.check_rounded));
await tester.tap(find.byIcon(Symbols.check_rounded));
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin');
+142 -20
View File
@@ -1,7 +1,12 @@
import 'dart:async';
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/focus/dpad_navigator.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
@@ -17,6 +22,8 @@ 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_media_card.dart';
import 'package:plezy/widgets/loading_indicator_box.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
@@ -30,18 +37,23 @@ void main() {
});
setUp(() async {
_resetGlobalTestState();
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
});
tearDown(_resetGlobalTestState);
testWidgets('stale callbacks are no-ops after SearchScreen is disposed', (tester) async {
final key = GlobalKey<State<SearchScreen>>();
final item = testMediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie 1',
serverId: 'server_1',
serverName: 'Server',
);
await tester.pumpWidget(
TranslationProvider(
@@ -59,7 +71,7 @@ void main() {
expect(tester.takeException(), isNull);
expect(() => (state as Refreshable).refresh(), returnsNormally);
expect(() => (state as dynamic).updateItem('movie_1'), returnsNormally);
expect(() => (state as dynamic).updateItem(item), returnsNormally);
expect(() => (state as FullRefreshable).fullRefresh(), returnsNormally);
expect(() => searchInput.submitSearchQuery('new movie'), returnsNormally);
expect(() => (state as FocusableTab).focusActiveTabIfReady(), returnsNormally);
@@ -67,17 +79,15 @@ void main() {
});
testWidgets('TV OSK search key moves focus to the first result', (tester) async {
final (client, key) = await _pumpTvSearchScreen(tester);
final (client, _) = await _pumpTvSearchScreen(tester);
await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
final state = key.currentState!;
_searchController(tester).text = 'movie';
// rate_limiter's Debounce compares DateTime.now() against the fake-clock
// timer, so it never invokes under FakeAsync — run the search via
// refresh() (same _performSearch path) to get results behind the dialog.
(state as Refreshable).refresh();
await tester.pumpAndSettle();
// Let the normal debounce populate results while the OSK remains open.
// DebouncedMediaSearch now uses a fake-clock-aware Timer.
await tester.pump(const Duration(milliseconds: 500));
await tester.pump();
expect(client.queries, ['movie']);
expect(find.text('Movie 1'), findsOneWidget);
@@ -87,9 +97,7 @@ void main() {
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
expect(find.text('Movie 1'), findsOneWidget);
// Dispose the screen so its still-armed debounce timer is cancelled.
await tester.pumpWidget(const SizedBox.shrink());
expect(client.queries, ['movie']);
});
testWidgets('TV OSK search key before the debounce fires searches immediately', (tester) async {
@@ -194,6 +202,86 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('card refresh stays server-qualified without restarting search', (tester) async {
final serverOneItem = testMediaItem(
id: 'shared-id',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Shared',
serverId: 'server_1',
serverName: 'Server One',
);
final serverTwoItem = testMediaItem(
id: 'shared-id',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Shared Alternate',
serverId: 'server_2',
serverName: 'Server Two',
);
final serverTwoClient = _FakeMediaServerClient(
serverIdValue: 'server_2',
serverNameValue: 'Server Two',
items: [serverTwoItem],
);
final (serverOneClient, key) = await _pumpTvSearchScreen(
tester,
items: [serverOneItem],
additionalClients: [serverTwoClient],
);
await tester.pumpAndSettle();
(key.currentState! as SearchInputFocusable).submitSearchQuery('shared');
await tester.pumpAndSettle();
expect(serverOneClient.queries, ['shared']);
expect(serverTwoClient.queries, ['shared']);
expect(find.byType(FocusableMediaCard), findsNWidgets(2));
// The exact-title match is the first, focused card. Keep the fetch open
// to observe the screen while the item-only refresh is in flight.
final sourceFinder = find.byKey(Key(serverOneItem.globalKey));
final untouchedFinder = find.byKey(Key(serverTwoItem.globalKey));
final sourceCardState = tester.state(sourceFinder);
final untouchedCardState = tester.state(untouchedFinder);
final focusedNode = FocusManager.instance.primaryFocus;
expect(focusedNode?.debugLabel, 'SearchFirstResult');
final fetchGate = Completer<void>();
final updated = serverOneItem.copyWith(title: 'Refreshed on Server One');
serverOneClient
..itemResult = updated
..fetchGate = fetchGate;
tester.widget<FocusableMediaCard>(sourceFinder).onRefresh!(serverOneItem);
await tester.pump();
// This used to fan the bare id out to every server and drive the whole
// screen through another search/loading pass.
expect(serverOneClient.fetchedItemIds, ['shared-id']);
expect(serverTwoClient.fetchedItemIds, isEmpty);
expect(serverOneClient.queries, ['shared']);
expect(serverTwoClient.queries, ['shared']);
expect(find.byWidget(LoadingIndicatorBox.sliver), findsNothing);
expect(find.byType(FocusableMediaCard), findsNWidgets(2));
expect(tester.state(sourceFinder), same(sourceCardState));
expect(tester.state(untouchedFinder), same(untouchedCardState));
expect(FocusManager.instance.primaryFocus, same(focusedNode));
fetchGate.complete();
await tester.pumpAndSettle();
expect(tester.widget<FocusableMediaCard>(sourceFinder).item, same(updated));
expect(tester.widget<FocusableMediaCard>(untouchedFinder).item, same(serverTwoItem));
expect(tester.state(sourceFinder), same(sourceCardState));
expect(tester.state(untouchedFinder), same(untouchedCardState));
expect(FocusManager.instance.primaryFocus, same(focusedNode));
expect(serverOneClient.queries, ['shared']);
expect(serverTwoClient.queries, ['shared']);
await tester.pumpWidget(const SizedBox.shrink());
});
}
Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchScreen(
@@ -202,6 +290,7 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
// When false, no server is registered, so performSearchQuery throws — the
// path a companion-remote submit hits when the search fails outright.
bool registerClient = true,
List<_FakeMediaServerClient> additionalClients = const [],
}) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
@@ -229,6 +318,9 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
);
final manager = MultiServerManager();
if (registerClient) manager.debugRegisterClientForTesting(client);
for (final additionalClient in additionalClients) {
manager.debugRegisterClientForTesting(additionalClient);
}
final provider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(provider.dispose);
@@ -244,13 +336,19 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
),
),
);
addTearDown(() async {
// Dispose the search state (including its debounce, focus nodes, and any
// hosted OSK route) before resetting process-wide focus/keyboard state.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
});
return (client, key);
}
Finder _keyboardDoneKey() {
return find.descendant(
of: find.byKey(const Key('tv_virtual_keyboard_panel')),
matching: find.byIcon(Icons.search_rounded),
matching: find.byIcon(Symbols.search_rounded),
);
}
@@ -258,17 +356,33 @@ TextEditingController _searchController(WidgetTester tester) {
return tester.widget<FocusableTextField>(find.byType(FocusableTextField)).controller;
}
void _resetGlobalTestState() {
FocusManager.instance.primaryFocus?.unfocus();
HardwareKeyboard.instance.clearState();
SelectKeyUpSuppressor.clearSuppression();
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
SettingsService.resetForTesting();
}
class _FakeMediaServerClient implements MediaServerClient {
final String serverIdValue;
final String serverNameValue;
final List<MediaItem> items;
final List<String> queries = [];
final List<String> fetchedItemIds = [];
MediaItem? itemResult;
Completer<void>? fetchGate;
_FakeMediaServerClient({required this.items});
_FakeMediaServerClient({required this.items, this.serverIdValue = 'server_1', this.serverNameValue = 'Server'});
@override
ServerId get serverId => ServerId('server_1');
ServerId get serverId => ServerId(serverIdValue);
@override
String? get serverName => 'Server';
String? get serverName => serverNameValue;
@override
MediaBackend get backend => MediaBackend.plex;
@@ -282,6 +396,14 @@ class _FakeMediaServerClient implements MediaServerClient {
return items;
}
@override
Future<MediaItem?> fetchItem(String id, {bool useCache = true}) async {
fetchedItemIds.add(id);
final gate = fetchGate;
if (gate != null) await gate.future;
return itemResult;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -0,0 +1,325 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/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/libraries_provider.dart';
import 'package:plezy/providers/seerr_account_provider.dart';
import 'package:plezy/providers/theme_provider.dart';
import 'package:plezy/providers/trackers_provider.dart';
import 'package:plezy/providers/trakt_account_provider.dart';
import 'package:plezy/screens/settings/settings_screen.dart';
import 'package:plezy/services/donation_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/update_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
import 'package:plezy/widgets/loading_indicator_box.dart';
import 'package:plezy/widgets/setting_tile.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/io_fakes.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late PathProviderPlatform originalPathProvider;
late Directory temporaryDirectory;
setUpAll(() {
originalPathProvider = PathProviderPlatform.instance;
LocaleSettings.setLocaleSync(AppLocale.en);
});
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
DownloadStorageService.resetForTesting();
temporaryDirectory = await Directory.systemTemp.createTemp('plezy_settings_screen_test_');
PathProviderPlatform.instance = FakePathProvider(temporaryDirectory);
TvDetectionService.debugSetAppleTVOverride(false);
PlatformDetector.debugSetIsDesktopOSOverride(false);
await SettingsService.getInstance();
});
tearDown(() async {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
DownloadStorageService.resetForTesting();
SettingsService.resetForTesting();
PathProviderPlatform.instance = originalPathProvider;
if (temporaryDirectory.existsSync()) {
await temporaryDirectory.delete(recursive: true);
}
});
testWidgets('migrated rows use the standard navigation geometry without losing activation', (tester) async {
final harness = await _pumpSettingsScreen(tester);
addTearDown(() => harness.dispose(tester));
final rows = <_MigratedRow>[
_MigratedRow(
title: t.settings.supportDeveloper,
focusLabel: 'settings_donate',
isVisible: DonationService.isEnabled,
),
_MigratedRow(title: t.settings.watchTogetherRelay, focusLabel: 'settings_watch_together_relay'),
_MigratedRow(title: t.settings.clearCache, focusLabel: 'settings_clear_cache'),
_MigratedRow(title: t.settings.resetSettings, focusLabel: 'settings_reset_settings'),
const _MigratedRow(title: 'Test Sentry', isVisible: kDebugMode),
const _MigratedRow(title: 'Test ANR', isVisible: kDebugMode),
_MigratedRow(title: t.settings.exportSettings, focusLabel: 'settings_export_settings'),
_MigratedRow(title: t.settings.importSettings, focusLabel: 'settings_import_settings'),
_MigratedRow(
title: t.settings.checkForUpdates,
focusLabel: 'settings_check_for_updates',
isVisible: UpdateService.isUpdateCheckEnabled && UpdateService.useNativeUpdater,
hasSubtitle: false,
),
];
final referenceHeight = tester.getSize(_focusableTileFor(t.settings.viewLogs)).height;
for (final row in rows) {
final navigationTile = _navigationTileFor(row.title);
if (!row.isVisible) {
expect(navigationTile, findsNothing, reason: '${row.title} must honor its production gate');
continue;
}
expect(navigationTile, findsOneWidget, reason: '${row.title} must use SettingNavigationTile');
final focusableFinder = _focusableTileWithin(navigationTile);
final focusable = tester.widget<FocusableListTile>(focusableFinder);
final materialTile = tester.widget<ListTile>(
find.descendant(of: focusableFinder, matching: find.byType(ListTile)),
);
expect(focusable.dense, isFalse, reason: '${row.title} must not inherit the compact tile default');
expect(focusable.visualDensity, VisualDensity.standard);
expect(materialTile.dense, isFalse);
expect(materialTile.visualDensity, VisualDensity.standard);
expect(focusable.onTap, isNotNull, reason: '${row.title} must remain pointer activatable');
expect(materialTile.onTap, isNotNull);
expect(materialTile.focusNode, isNotNull, reason: '${row.title} must remain D-pad focusable');
expect(materialTile.focusNode!.canRequestFocus, isTrue);
final pointerRegions = tester.widgetList<MouseRegion>(
find.descendant(of: focusableFinder, matching: find.byType(MouseRegion)),
);
expect(pointerRegions.any((region) => region.cursor == SystemMouseCursors.click), isTrue);
if (row.focusLabel != null) {
expect(focusable.focusNode?.debugLabel, row.focusLabel);
expect(materialTile.focusNode, same(focusable.focusNode));
} else {
expect(focusable.focusNode, isNull, reason: '${row.title} intentionally uses the tile-owned focus node');
}
if (row.hasSubtitle) {
// Subtitle rows occupy the same vertical space as an existing standard
// SettingNavigationTile in this screen.
expect(tester.getSize(focusableFinder).height, referenceHeight);
}
}
// Exercise both input paths against real callbacks rather than merely
// checking that callbacks are non-null.
final relayMaterialTile = tester.widget<ListTile>(
find.descendant(of: _focusableTileFor(t.settings.watchTogetherRelay), matching: find.byType(ListTile)),
);
relayMaterialTile.focusNode!.requestFocus();
await tester.pump();
expect(relayMaterialTile.focusNode!.hasFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.watchTogetherRelay), findsWidgets);
Navigator.of(tester.element(find.byType(AlertDialog))).pop();
await tester.pumpAndSettle();
await tester.tap(find.text(t.settings.clearCache));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.clearCache), findsWidgets);
});
testWidgets('special download and generic update rows retain rich content at standard density', (tester) async {
final harness = await _pumpSettingsScreen(tester);
addTearDown(() => harness.dispose(tester));
if (Platform.isIOS) {
expect(find.text(t.settings.downloadLocationDefault), findsNothing);
} else {
final downloadTileFinder = _focusableTileFor(t.settings.downloadLocationDefault);
final materialDownloadTile = tester.widget<ListTile>(
find.descendant(of: downloadTileFinder, matching: find.byType(ListTile)),
);
final downloadTile = tester.widget<FocusableListTile>(downloadTileFinder);
final subtitle = downloadTile.subtitle! as Text;
expect(_navigationTileFor(t.settings.downloadLocationDefault), findsNothing);
expect(materialDownloadTile.dense, isFalse);
expect(materialDownloadTile.visualDensity, VisualDensity.standard);
expect(downloadTile.dense, isFalse);
expect(downloadTile.visualDensity, VisualDensity.standard);
expect(downloadTile.leading, isA<AppIcon>());
expect(downloadTile.trailing, isA<AppIcon>());
expect(subtitle.maxLines, 2);
expect(subtitle.overflow, TextOverflow.ellipsis);
expect(downloadTile.onTap, isNotNull);
await tester.tap(find.text(t.settings.downloadLocationDefault));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.downloadLocationDescription), findsOneWidget);
Navigator.of(tester.element(find.byType(AlertDialog))).pop();
await tester.pumpAndSettle();
}
if (!UpdateService.isUpdateCheckEnabled) {
expect(find.text(t.settings.checkForUpdates), findsNothing);
return;
}
if (UpdateService.useNativeUpdater) {
expect(_navigationTileFor(t.settings.checkForUpdates), findsOneWidget);
return;
}
final updateTileFinder = _focusableTileFor(t.settings.checkForUpdates);
final materialUpdateTile = tester.widget<ListTile>(
find.descendant(of: updateTileFinder, matching: find.byType(ListTile)),
);
final updateTile = tester.widget<FocusableListTile>(updateTileFinder);
expect(_navigationTileFor(t.settings.checkForUpdates), findsNothing);
expect(updateTile.dense, isFalse);
expect(materialUpdateTile.dense, isFalse);
expect(materialUpdateTile.visualDensity, VisualDensity.standard);
expect(updateTile.visualDensity, VisualDensity.standard);
expect(updateTile.trailing, isA<AppIcon>());
expect(updateTile.onTap, isNotNull);
expect(find.descendant(of: updateTileFinder, matching: find.byType(LoadingIndicatorBox)), findsNothing);
// The generic updater deliberately remains a FocusableListTile: unlike a
// SettingNavigationTile, its trailing slot can be replaced by the progress
// indicator and its callback disabled while a request is in flight.
expect(materialUpdateTile.focusNode, isNotNull);
});
}
Finder _navigationTileFor(String title) =>
find.ancestor(of: find.text(title), matching: find.byType(SettingNavigationTile));
Finder _focusableTileFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(FocusableListTile));
Finder _focusableTileWithin(Finder navigationTile) =>
find.descendant(of: navigationTile, matching: find.byType(FocusableListTile));
class _MigratedRow {
const _MigratedRow({required this.title, this.focusLabel, this.isVisible = true, this.hasSubtitle = true});
final String title;
final String? focusLabel;
final bool isVisible;
final bool hasSubtitle;
}
class _SettingsHarness {
_SettingsHarness({
required this.database,
required this.plexHome,
required this.activeProfile,
required this.libraries,
required this.theme,
required this.trakt,
required this.trackers,
required this.seerr,
});
final AppDatabase database;
final PlexHomeService plexHome;
final ActiveProfileProvider activeProfile;
final LibrariesProvider libraries;
final ThemeProvider theme;
final TraktAccountProvider trakt;
final TrackersProvider trackers;
final SeerrAccountProvider seerr;
Future<void> dispose(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
libraries.dispose();
theme.dispose();
trakt.dispose();
trackers.dispose();
seerr.dispose();
activeProfile.dispose();
await plexHome.dispose();
await database.close();
}
}
Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async {
tester.view.physicalSize = const Size(1800, 3200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final database = AppDatabase.forTesting(NativeDatabase.memory());
final connections = ConnectionRegistry(database);
final profileConnections = ProfileConnectionRegistry(database);
final profiles = ProfileRegistry(database);
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections);
final libraries = LibrariesProvider();
final theme = ThemeProvider();
final trakt = TraktAccountProvider();
final trackers = TrackersProvider();
final seerr = SeerrAccountProvider();
final harness = _SettingsHarness(
database: database,
plexHome: plexHome,
activeProfile: activeProfile,
libraries: libraries,
theme: theme,
trakt: trakt,
trackers: trackers,
seerr: seerr,
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
ChangeNotifierProvider<LibrariesProvider>.value(value: libraries),
ChangeNotifierProvider<ThemeProvider>.value(value: theme),
ChangeNotifierProvider<TraktAccountProvider>.value(value: trakt),
ChangeNotifierProvider<TrackersProvider>.value(value: trackers),
ChangeNotifierProvider<SeerrAccountProvider>.value(value: seerr),
],
child: MaterialApp(theme: monoTheme(dark: true), home: const SettingsScreen()),
),
),
);
await tester.pumpAndSettle();
return harness;
}
@@ -90,6 +90,25 @@ void main() {
await expectLater(svc.probe('https://jf.example.com'), throwsA(isA<MediaServerUrlException>()));
});
test('applies the shared jellyfinProbe timeout to the injected auth client', () {
fakeAsync((async) {
final response = Completer<http.Response>();
final svc = _service(handler: (_) => response.future);
Object? probeError;
unawaited(_captureError(svc.probe('https://jf.example.com')).then((error) => probeError = error));
async.flushMicrotasks();
async.elapse(MediaServerTimeouts.jellyfinProbe - const Duration(milliseconds: 1));
async.flushMicrotasks();
expect(probeError, isNull);
async.elapse(const Duration(milliseconds: 2));
async.flushMicrotasks();
expect(probeError, isA<MediaServerUrlException>());
});
});
test('registers base URL redaction before the first probe request', () async {
final svc = _service(
handler: (req) {
@@ -229,6 +229,26 @@ void main() {
expect(info.defaultSubtitleStreamIndex, 4);
});
test('coerces Jellyfin string and numeric stream scalars', () {
final info = jellyfinMediaSourceToMediaSourceInfo({
'Id': 'src-flexible',
'DefaultAudioStreamIndex': '2',
'DefaultSubtitleStreamIndex': 3.0,
'MediaStreams': [
{'Index': '2', 'Type': 'Audio', 'Channels': '6'},
{'Index': 3.0, 'Type': 'Subtitle'},
],
});
expect(info.defaultAudioStreamIndex, 2);
expect(info.defaultSubtitleStreamIndex, 3);
expect(info.audioTracks.single.id, 2);
expect(info.audioTracks.single.channels, 6);
expect(info.audioTracks.single.selected, isTrue);
expect(info.subtitleTracks.single.id, 3);
expect(info.subtitleTracks.single.selected, isTrue);
});
test('falls back to Language when DisplayLanguage absent', () {
final info = jellyfinMediaSourceToMediaSourceInfo({
'MediaStreams': [
@@ -506,6 +526,30 @@ void main() {
expect(versions[1].videoResolution, '1080');
});
test('coerces source scalars and rounds bps to kbps without zero sentinels', () {
final versions = jellyfinSourcesToVersions([
{
'Id': 'rounded',
'Width': '1920',
'Height': 1080.9,
'Bitrate': '1500',
'Size': '987654321',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264'},
],
},
{'Id': 'missing-bitrate'},
{'Id': 'zero-bitrate', 'Bitrate': 0},
{'Id': 'negative-bitrate', 'Bitrate': -1000},
]);
expect(versions.first.width, 1920);
expect(versions.first.height, 1080);
expect(versions.first.bitrate, 2);
expect(versions.first.parts.single.sizeBytes, 987654321);
expect(versions.skip(1).map((version) => version.bitrate), everyElement(isNull));
});
test('handles missing MediaStreams + missing Height gracefully', () {
final versions = jellyfinSourcesToVersions([
{'Id': 'x', 'Name': 'X', 'Container': 'mkv'},
@@ -142,4 +142,151 @@ void main() {
expect(captured?.url.queryParameters, containsPair('genre[].tag.tag-', 'Science%20Fiction'));
expect(captured?.url.queryParameters, containsPair('genre.locked', '1'));
});
test('cached child fetch rejects decodable HTTP error responses before caching', () async {
for (final statusCode in [404, 500]) {
final parentId = 'parent-$statusCode';
final endpoint = '/library/metadata/$parentId/children';
var requestCount = 0;
final client = makeClient((request) async {
requestCount++;
return http.Response(
jsonEncode({
'MediaContainer': {
'size': 1,
'totalSize': 1,
'Metadata': [
{'ratingKey': 'error-child', 'type': 'season', 'title': 'Must Not Parse'},
],
},
}),
statusCode,
headers: {'content-type': 'application/json'},
);
});
addTearDown(client.close);
await expectLater(
client.fetchChildren(parentId),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.statusCode, 'statusCode', statusCode)
.having((error) => error.requestUri?.path, 'request path', endpoint),
),
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), isNull);
}
});
test('HTTP failure falls back to the existing child cache without replacing it', () async {
const parentId = 'cached-parent';
const endpoint = '/library/metadata/$parentId/children';
final cachedResponse = {
'MediaContainer': {
'size': 1,
'totalSize': 1,
'Metadata': [
{'ratingKey': 'cached-child', 'type': 'season', 'title': 'Cached Season'},
],
},
};
await PlexApiCache.instance.put(ServerId('server-id'), endpoint, cachedResponse);
var requestCount = 0;
final client = makeClient((request) async {
requestCount++;
return http.Response(
jsonEncode({
'MediaContainer': {
'size': 1,
'totalSize': 1,
'Metadata': [
{'ratingKey': 'error-child', 'type': 'season', 'title': 'Must Not Replace Cache'},
],
},
}),
500,
headers: {'content-type': 'application/json'},
);
});
addTearDown(client.close);
final children = await client.fetchChildren(parentId);
expect(requestCount, 1);
expect(children.map((child) => child.id), ['cached-child']);
expect(children.single.title, 'Cached Season');
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), cachedResponse);
});
test('successful child fetch parses and caches the response', () async {
const parentId = 'fresh-parent';
const endpoint = '/library/metadata/$parentId/children';
final responseData = {
'MediaContainer': {
'size': 1,
'totalSize': 1,
'Metadata': [
{'ratingKey': 'fresh-child', 'type': 'season', 'title': 'Fresh Season'},
],
},
};
var requestCount = 0;
final client = makeClient((request) async {
requestCount++;
return http.Response(jsonEncode(responseData), 200, headers: {'content-type': 'application/json'});
});
addTearDown(client.close);
final children = await client.fetchChildren(parentId);
expect(requestCount, 1);
expect(children.map((child) => child.id), ['fresh-child']);
expect(children.single.title, 'Fresh Season');
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), responseData);
});
test('child retrieval walks every page and caches the combined result', () async {
const parentId = 'paged-parent';
const endpoint = '/library/metadata/$parentId/children';
final requests = <Uri>[];
final client = makeClient((request) async {
requests.add(request.url);
final start = int.parse(request.url.queryParameters['X-Plex-Container-Start']!);
final metadata = start == 0
? [
{'ratingKey': 'season-1', 'type': 'season', 'title': 'Season 1'},
{'ratingKey': 'season-2', 'type': 'season', 'title': 'Season 2'},
]
: [
{'ratingKey': 'season-3', 'type': 'season', 'title': 'Season 3'},
];
return http.Response(
jsonEncode({
'MediaContainer': {'size': metadata.length, 'totalSize': 3, 'Metadata': metadata},
}),
200,
headers: {'content-type': 'application/json'},
);
});
addTearDown(client.close);
final children = await client.fetchChildren(parentId);
final cached = await PlexApiCache.instance.get(ServerId('server-id'), endpoint);
final cachedContainer = cached!['MediaContainer'] as Map<String, dynamic>;
final cachedMetadata = cachedContainer['Metadata'] as List<dynamic>;
expect(children.map((child) => child.id), ['season-1', 'season-2', 'season-3']);
expect(requests.map((uri) => uri.queryParameters['X-Plex-Container-Start']), ['0', '2']);
expect(requests.every((uri) => uri.queryParameters['X-Plex-Container-Size'] == '200'), isTrue);
expect(requests.every((uri) => uri.queryParameters['includeStreams'] == '1'), isTrue);
expect(cachedContainer['size'], 3);
expect(cachedContainer['totalSize'], 3);
expect(cachedMetadata.map((item) => (item as Map<String, dynamic>)['ratingKey']), [
'season-1',
'season-2',
'season-3',
]);
});
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:logger/logger.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/utils/app_logger.dart' as logging;
import 'package:plezy/utils/error_message_utils.dart';
void main() {
late Logger originalLogger;
late _RecordingLogOutput logOutput;
setUpAll(() async => LocaleSettings.setLocale(AppLocale.es));
tearDownAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
setUp(() {
originalLogger = logging.appLogger;
logOutput = _RecordingLogOutput();
logging.appLogger = Logger(printer: SimplePrinter(), output: logOutput);
});
tearDown(() {
logging.appLogger = originalLogger;
});
group('localizedLoadErrorMessage', () {
test('distinguishes connection timeouts from connection failures', () {
for (final type in [MediaServerHttpErrorType.connectionTimeout, MediaServerHttpErrorType.receiveTimeout]) {
final message = localizedLoadErrorMessage(
MediaServerHttpException(type: type, message: 'private timeout detail'),
StackTrace.current,
context: 'Biblioteca',
);
expect(message, 'Tiempo de conexión agotado al cargar Biblioteca');
expect(message, isNot(contains('private timeout detail')));
}
final connectionMessage = localizedLoadErrorMessage(
MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'private connection detail'),
StackTrace.current,
context: 'Biblioteca',
);
expect(connectionMessage, 'No se puede conectar al servidor multimedia');
expect(connectionMessage, isNot(contains('private connection detail')));
});
test('logs an unexpected failure once and returns only the localized fallback', () {
const privateDetail = 'token=super-secret';
final message = localizedLoadErrorMessage(StateError(privateDetail), StackTrace.current, context: 'Biblioteca');
expect(message, 'No se pudo cargar Biblioteca. Inténtalo de nuevo.');
expect(message, isNot(contains(privateDetail)));
expect(logOutput.writeCount, 1);
});
test('never exposes server status text, response data, or exception messages', () {
const serverText = 'database failed for user@example.com';
final error = MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: 500,
message: serverText,
responseData: {'error': serverText},
);
final message = localizedLoadErrorMessage(error, StackTrace.current, context: 'Biblioteca');
expect(message, 'No se pudo cargar Biblioteca. Inténtalo de nuevo.');
expect(message, isNot(contains('500')));
expect(message, isNot(contains(serverText)));
});
});
}
class _RecordingLogOutput extends LogOutput {
int writeCount = 0;
@override
void output(OutputEvent event) {
writeCount++;
}
}
+80
View File
@@ -1,9 +1,15 @@
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.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/services/device_performance.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/media_image_helper.dart';
import '../test_helpers/media_items.dart';
/// Only [thumbnailUrl] is exercised; everything else throws via noSuchMethod.
class _SizedUrlFakeClient implements MediaServerClient {
@override
@@ -14,6 +20,8 @@ class _SizedUrlFakeClient implements MediaServerClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
MediaItem _item(MediaKind kind) => testMediaItem(kind: kind);
void main() {
group('MediaImageHelper.getOptimizedImageUrl', () {
test('adds size hints to absolute Jellyfin artwork URLs', () {
@@ -134,6 +142,78 @@ void main() {
});
});
group('MediaImageHelper image type budgets', () {
tearDown(DevicePerformance.debugReset);
test('hero logos get a larger budget without changing ordinary logos', () {
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.logo),
(600, 300),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.heroLogo),
(1000, 500),
);
});
test('card artwork follows square, wide, and poster media shapes', () {
for (final kind in [MediaKind.artist, MediaKind.album, MediaKind.track]) {
expect(
MediaImageHelper.cardImageType(_item(kind), EpisodePosterMode.episodeThumbnail),
ImageType.square,
reason: '${kind.id} artwork must keep its square music cache budget',
);
}
expect(
MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.episodeThumbnail),
ImageType.thumb,
);
expect(
MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.seriesPoster),
ImageType.poster,
);
expect(
MediaImageHelper.cardImageType(_item(MediaKind.movie), EpisodePosterMode.episodeThumbnail),
ImageType.poster,
);
});
});
group('MediaImageHelper.serverArtworkProvider', () {
test('reuses cache identity and disk key while bounding both decode axes', () {
const url = 'https://example.invalid/library/poster.jpg';
final first = MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage;
final second =
MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage;
final firstCached = first.imageProvider as CachedNetworkImageProvider;
final secondCached = second.imageProvider as CachedNetworkImageProvider;
expect(firstCached, secondCached);
expect(first.width, second.width);
expect(first.width, 640);
expect(first.height, second.height);
expect(first.height, 360);
expect(first.policy, second.policy);
expect(first.policy, ResizeImagePolicy.fit);
expect(first.allowUpscaling, second.allowUpscaling);
expect(first.allowUpscaling, isFalse);
expect(firstCached.url, secondCached.url);
expect(firstCached.url, url);
expect(firstCached.headers, secondCached.headers);
expect(firstCached.headers, const {'User-Agent': 'Plezy'});
expect(firstCached.cacheKey, secondCached.cacheKey);
expect(firstCached.cacheKey, isNotNull);
expect(firstCached.maxWidth, secondCached.maxWidth);
expect(firstCached.maxWidth, isNull);
expect(firstCached.maxHeight, secondCached.maxHeight);
expect(firstCached.maxHeight, isNull);
});
});
group('MediaImageHelper.boundedDecode', () {
test('bounds both axes with fit policy (no distortion, no upscale)', () {
const base = NetworkImage('https://example/img');
+4 -3
View File
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
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/focus/focusable_text_field.dart';
import 'package:plezy/services/gamepad_service.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -713,7 +714,7 @@ void main() {
rebuild(() => onNavigateDown = () => navigateDownCalls++);
await tester.pump();
await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded));
await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded));
await tester.pumpAndSettle();
expect(navigateDownCalls, 1);
@@ -765,7 +766,7 @@ void main() {
});
await tester.pump();
await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded));
await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded));
await tester.pumpAndSettle();
expect(submitted, 'query');
@@ -799,7 +800,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded));
await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded));
await tester.pumpAndSettle();
await tester.pump();
+269
View File
@@ -11,6 +11,7 @@ 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';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
@@ -27,12 +28,19 @@ 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/screens/music/album_detail_screen.dart';
import 'package:plezy/screens/music/artist_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/music/music_playback_service.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/services/plex_client.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
@@ -40,6 +48,7 @@ import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/media_context_menu.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -303,6 +312,102 @@ void main() {
expect(find.text(t.playlists.selectPlaylist), findsNothing);
expect(find.text('picker target'), findsOneWidget);
});
testWidgets('track album action uses the profile navigator from the sibling menu overlay', (tester) async {
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
serverId: 'srv-1',
);
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [album]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToAlbum);
expect(find.byType(AlbumDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(AlbumDetailScreen)), listen: false),
same(harness.music),
);
});
testWidgets('album artist action uses the profile navigator from the sibling menu overlay', (tester) async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: 'srv-1',
);
final artist = testMediaItem(
id: 'artist-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: [artist]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist);
expect(find.byType(ArtistDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(ArtistDetailScreen)), listen: false),
same(harness.music),
);
});
testWidgets('track artist action uses the profile navigator from the sibling menu overlay', (tester) async {
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
serverId: 'srv-1',
);
final artist = testMediaItem(
id: 'artist-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [artist]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist);
expect(find.byType(ArtistDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(ArtistDetailScreen)), listen: false),
same(harness.music),
);
});
});
}
@@ -467,6 +572,170 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
}
}
class _RelatedMusicClient implements MediaServerClient {
_RelatedMusicClient(Iterable<MediaItem> items) : _items = {for (final item in items) item.id: item};
final Map<String, MediaItem> _items;
@override
ServerId get serverId => ServerId('srv-1');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<MediaItem?> fetchItem(String id) async => _items[id];
@override
Future<List<MediaItem>> fetchAlbumTracks(String albumId) async => const [];
@override
Future<List<MediaItem>> fetchArtistAlbums(String artistId) async => const [];
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _SiblingMusicMenuHarness {
const _SiblingMusicMenuHarness({
required this.rootNavigatorKey,
required this.profileNavigatorKey,
required this.menuKey,
required this.music,
});
final GlobalKey<NavigatorState> rootNavigatorKey;
final GlobalKey<NavigatorState> profileNavigatorKey;
final GlobalKey<MediaContextMenuState> menuKey;
final _RecordingMusicPlaybackService music;
}
Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
WidgetTester tester, {
required MediaItem item,
required List<MediaItem> relatedItems,
}) async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
LocaleSettings.setLocaleSync(AppLocale.en);
final db = AppDatabase.forTesting(NativeDatabase.memory());
JellyfinApiCache.initialize(db);
final downloadManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
);
downloadManager.recoveryFuture = Future<void>.value();
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await downloadProvider.ensureInitialized();
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 music = _RecordingMusicPlaybackService();
final rootNavigatorKey = GlobalKey<NavigatorState>();
final profileNavigatorKey = GlobalKey<NavigatorState>();
final menuKey = GlobalKey<MediaContextMenuState>();
addTearDown(() async {
downloadProvider.dispose();
downloadManager.dispose();
activeProfileProvider.dispose();
await plexHome.dispose();
music.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
navigatorKey: rootNavigatorKey,
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.macOS),
home: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<MusicPlaybackService>.value(value: music),
],
child: ProfileNavigationScope(
navigatorKey: profileNavigatorKey,
routeObserver: RouteObserver<PageRoute<dynamic>>(),
mainScaffoldMessengerKey: GlobalKey<ScaffoldMessengerState>(),
child: Stack(
fit: StackFit.expand,
children: [
Navigator(
key: profileNavigatorKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Center(child: Text('profile content'))),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Material(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(
width: 180,
height: 64,
child: Center(child: Text('mini-player menu target')),
),
),
),
),
],
),
),
),
),
),
);
return _SiblingMusicMenuHarness(
rootNavigatorKey: rootNavigatorKey,
profileNavigatorKey: profileNavigatorKey,
menuKey: menuKey,
music: music,
);
}
Future<void> _selectSiblingMusicMenuAction(
WidgetTester tester,
_SiblingMusicMenuHarness harness,
String actionLabel,
) async {
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
await tester.pumpAndSettle();
await tester.tap(find.text(actionLabel));
await tester.pumpAndSettle();
}
PlexHomeUser _homeUser({required bool admin}) {
return PlexHomeUser(
id: 0,
+14 -6
View File
@@ -292,8 +292,11 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text(t.common.play), findsOneWidget);
final menuSurface = find.byWidgetPredicate((widget) => widget is Material && widget.elevation == 3);
final playMenuIcon = find.byWidgetPredicate(
(widget) => widget is AppIcon && widget.icon == Symbols.play_arrow_rounded,
);
expect(playMenuIcon, findsOneWidget);
final menuSurface = find.ancestor(of: playMenuIcon, matching: find.byType(BottomSheet));
expect(menuSurface, findsOneWidget);
expect(tester.getCenter(menuSurface).dx, closeTo(cardRect.center.dx, 0.1));
@@ -301,18 +304,23 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('stays hidden while the route observer suppresses it', (tester) async {
testWidgets('stays hidden while a named suppressing route is active', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
observer.suppress.value = true;
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
final route = MaterialPageRoute<void>(
settings: const RouteSettings(name: kNowPlayingRouteName),
builder: (_) => const SizedBox.shrink(),
);
observer.didPush(route, null);
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
// Suppression lifting brings it back without a rebuild from the service.
observer.suppress.value = false;
observer.didRemove(route, null);
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
});
+43 -11
View File
@@ -9,28 +9,49 @@ import 'package:plezy/utils/media_image_helper.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
void main() {
testWidgets('network images use decode resize without disk cache resize', (tester) async {
testWidgets('network widget matches the shared server artwork provider', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
const imageUrl = 'https://example.invalid/episode-thumb.jpg';
await tester.pumpWidget(
const MaterialApp(
home: SizedBox(
width: 160,
height: 90,
child: OptimizedMediaImage.thumb(
imagePath: 'https://example.invalid/episode-thumb.jpg',
width: 160,
height: 90,
),
child: OptimizedMediaImage.thumb(imagePath: imageUrl, width: 160, height: 90),
),
),
);
final image = tester.widget<Image>(find.byType(Image));
final resizeProvider = image.image as ResizeImage;
final cachedProvider = resizeProvider.imageProvider as CachedNetworkImageProvider;
final widgetProvider = image.image as ResizeImage;
final widgetCached = widgetProvider.imageProvider as CachedNetworkImageProvider;
final sharedProvider =
MediaImageHelper.serverArtworkProvider(imageUrl: imageUrl, memWidth: 200, memHeight: 180) as ResizeImage;
final sharedCached = sharedProvider.imageProvider as CachedNetworkImageProvider;
expect(resizeProvider.height, isNotNull);
expect(cachedProvider.maxHeight, isNull);
expect(cachedProvider.maxWidth, isNull);
expect(widgetCached, sharedCached);
expect(widgetProvider.width, sharedProvider.width);
expect(widgetProvider.width, 200);
expect(widgetProvider.height, sharedProvider.height);
expect(widgetProvider.height, 180);
expect(widgetProvider.policy, sharedProvider.policy);
expect(widgetProvider.policy, ResizeImagePolicy.fit);
expect(widgetProvider.allowUpscaling, sharedProvider.allowUpscaling);
expect(widgetProvider.allowUpscaling, isFalse);
expect(widgetCached.url, sharedCached.url);
expect(widgetCached.url, imageUrl);
expect(widgetCached.cacheKey, sharedCached.cacheKey);
expect(widgetCached.cacheKey, isNotNull);
expect(widgetCached.headers, sharedCached.headers);
expect(widgetCached.headers, const {'User-Agent': 'Plezy'});
expect(widgetCached.maxWidth, sharedCached.maxWidth);
expect(widgetCached.maxWidth, isNull);
expect(widgetCached.maxHeight, sharedCached.maxHeight);
expect(widgetCached.maxHeight, isNull);
});
testWidgets('failed image placeholders keep explicit dimensions in loose layouts', (tester) async {
@@ -69,6 +90,10 @@ void main() {
});
testWidgets('same local artwork path re-resolves after the file appears', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final directory = Directory.systemTemp.createTempSync('plezy-image-test');
addTearDown(() => directory.deleteSync(recursive: true));
final file = File('${directory.path}/poster.png');
@@ -110,6 +135,13 @@ void main() {
expect(file.existsSync(), isTrue);
expect(find.byIcon(Symbols.image_not_supported_rounded), findsNothing);
expect(find.byType(Image), findsOneWidget);
final localImage = tester.widget<Image>(find.byType(Image));
final localResize = localImage.image as ResizeImage;
final localProvider = localResize.imageProvider as FileImage;
expect(localResize.width, 120);
expect(localResize.height, 180);
expect(localResize.policy, ResizeImagePolicy.fit);
expect(localProvider.file.path, file.path);
await tester.pumpWidget(const SizedBox.shrink());
});
}
+3 -2
View File
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
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/screens/profile/pin_entry_dialog.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -197,8 +198,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
expect(find.byIcon(Icons.close_rounded), findsOneWidget);
expect(find.byIcon(Icons.backspace_outlined), findsOneWidget);
expect(find.byIcon(Symbols.close_rounded), findsOneWidget);
expect(find.byIcon(Symbols.backspace_rounded), findsOneWidget);
expect(find.byType(TextField), findsNothing);
expect(find.widgetWithText(TextButton, 'Cancel'), findsNothing);
expect(find.byType(FilledButton), findsNothing);
@@ -7,6 +7,7 @@ import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/providers/companion_remote_provider.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/companion_remote/remote_session_dialog.dart';
import 'package:provider/provider.dart';
@@ -106,6 +107,7 @@ Future<void> _pumpDialogLauncher(WidgetTester tester, CompanionRemoteProvider pr
ChangeNotifierProvider<CompanionRemoteProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Builder(
builder: (context) {
return Scaffold(
+3 -2
View File
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
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/focus/dpad_navigator.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/tv_virtual_keyboard.dart';
@@ -153,9 +154,9 @@ void main() {
await _pumpKeyboard(tester, controller: controller);
expect(find.text('='), findsOneWidget);
expect(find.byIcon(Icons.functions_rounded), findsOneWidget);
expect(find.byIcon(Symbols.functions_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.functions_rounded));
await tester.tap(find.byIcon(Symbols.functions_rounded));
await tester.pumpAndSettle();
expect(find.text('ABC'), findsOneWidget);
+53 -2
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/player.dart';
import 'package:plezy/mpv/player/player_state.dart';
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';
@@ -30,12 +33,24 @@ const _testTokens = MonoTokens(
);
void main() {
setUpAll(() async {
await LocaleSettings.setLocale(AppLocale.ru);
LocaleSettings.setLocaleSync(AppLocale.en);
});
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
SleepTimerService().cancelTimer();
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
tearDown(() {
SleepTimerService().cancelTimer();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('shows audio passthrough on supported TV-style surfaces', (tester) async {
await _pumpSheet(tester);
@@ -43,9 +58,45 @@ void main() {
expect(find.text('Audio Passthrough'), findsOneWidget);
});
testWidgets('localizes Off, Normal, and Active video setting values', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.ru);
await _pumpSheet(tester, canControl: true);
expect(find.text('Обычная'), findsOneWidget);
expect(find.text('Выкл.'), findsOneWidget);
final sleepTimer = SleepTimerService();
sleepTimer.startTimer(const Duration(hours: 1), () {});
try {
await tester.pump();
expect(find.textContaining('Активен ('), findsOneWidget);
} finally {
sleepTimer.cancelTimer();
}
});
testWidgets('localizes every ASS subtitle override enum label', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.ru);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: const SubtitleStylingScreen(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Переопределение ASS'));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
for (final label in ['Нет', 'Да', 'Масштабировать', 'Принудительно', 'Удалить стили']) {
expect(find.descendant(of: dialog, matching: find.text(label)), findsOneWidget);
}
});
}
Future<void> _pumpSheet(WidgetTester tester) async {
Future<void> _pumpSheet(WidgetTester tester, {bool canControl = false}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
@@ -57,7 +108,7 @@ Future<void> _pumpSheet(WidgetTester tester) async {
player: _FakeSettingsPlayer(),
audioSyncOffset: 0,
subtitleSyncOffset: 0,
canControl: false,
canControl: canControl,
),
),
),