fix: eliminate cross-app consistency drift
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,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,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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user