@@ -0,0 +1,103 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/libraries/folder_tree_view.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/jellyfin_client.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:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/backend_client_fixtures.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
});
|
||||
|
||||
testWidgets('Jellyfin music artists and albums expand through direct children', (tester) async {
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: testJellyfinConnection(machineId: 'music-server'),
|
||||
httpClient: MockClient((request) async {
|
||||
final parentId = request.url.queryParameters['ParentId'];
|
||||
final foldersOnly = request.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder';
|
||||
final items = foldersOnly
|
||||
? const <Map<String, Object?>>[]
|
||||
: switch (parentId) {
|
||||
'music-library' => const [
|
||||
{'Id': 'artist-1', 'Name': 'Artist One', 'Type': 'MusicArtist', 'IsFolder': true},
|
||||
],
|
||||
'artist-1' => const [
|
||||
{
|
||||
'Id': 'album-1',
|
||||
'Name': 'Album One',
|
||||
'Type': 'MusicAlbum',
|
||||
'IsFolder': true,
|
||||
'AlbumArtists': [
|
||||
{'Id': 'artist-1', 'Name': 'Artist One'},
|
||||
],
|
||||
},
|
||||
],
|
||||
'album-1' => const [
|
||||
{'Id': 'track-1', 'Name': 'Track One', 'Type': 'Audio', 'AlbumId': 'album-1', 'Album': 'Album One'},
|
||||
],
|
||||
_ => const <Map<String, Object?>>[],
|
||||
};
|
||||
return http.Response(
|
||||
jsonEncode({'Items': items, 'TotalRecordCount': items.length}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
addTearDown(() {
|
||||
provider.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: provider,
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: const [
|
||||
FolderTreeView(libraryKey: 'music-library', serverId: 'music-server', libraryKind: MediaKind.artist),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Artist One'), findsOneWidget);
|
||||
expect(find.text('Album One'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Artist One'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Album One'), findsOneWidget);
|
||||
expect(find.text('Track One'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Album One'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Track One'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/focus/focusable_button.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_library.dart';
|
||||
import 'package:plezy/navigation/main_screen_scope.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/libraries/state_messages.dart';
|
||||
import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/focusable_filter_chip.dart';
|
||||
import 'package:plezy/widgets/media_card.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/backend_client_fixtures.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
final _musicLibrary = MediaLibrary(
|
||||
id: 'music-library',
|
||||
backend: MediaBackend.jellyfin,
|
||||
title: 'Music',
|
||||
kind: MediaKind.artist,
|
||||
serverId: ServerId('music-server'),
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
await StorageService.getInstance();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('TV full-card preference keeps music browse captions visible', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
|
||||
final harness = _MusicBrowseHarness();
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpBrowseTab(tester, harness);
|
||||
|
||||
expect(harness.browseRequestCount, 1);
|
||||
final card = tester.widget<MediaCard>(find.byType(MediaCard).first);
|
||||
expect(card.fullBleedImage, isFalse);
|
||||
expect(find.text('Artist One'), findsOneWidget);
|
||||
expect(find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipOval)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('D-pad down focuses Retry and select reloads music browse', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final harness = _MusicBrowseHarness(failFirstBrowse: true);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpBrowseTab(tester, harness);
|
||||
|
||||
expect(find.byType(ErrorStateWidget), findsOneWidget);
|
||||
expect(harness.browseRequestCount, 1);
|
||||
|
||||
final groupingChip = tester.widget<FocusableFilterChip>(find.byType(FocusableFilterChip).first);
|
||||
groupingChip.focusNode!.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
final retryFinder = find.descendant(of: find.byType(ErrorStateWidget), matching: find.byType(FocusableButton));
|
||||
final retry = tester.widget<FocusableButton>(retryFinder);
|
||||
expect(retry.focusNode, isNotNull);
|
||||
expect(retry.focusNode!.hasFocus, isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await _pumpRequestFrames(tester);
|
||||
|
||||
expect(harness.browseRequestCount, 2);
|
||||
expect(find.byType(ErrorStateWidget), findsNothing);
|
||||
expect(find.text('Artist One'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
addTearDown(() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
tester.view.resetPhysicalSize();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: harness.provider,
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: MainScreenFocusScope(
|
||||
focusSidebar: () {},
|
||||
focusContent: () {},
|
||||
isSidebarFocused: false,
|
||||
sideNavigationWidth: 0,
|
||||
child: Scaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)),
|
||||
),
|
||||
],
|
||||
body: LibraryBrowseTab(
|
||||
library: _musicLibrary,
|
||||
canGroupByFolders: true,
|
||||
suppressAutoFocus: true,
|
||||
onBack: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await _pumpRequestFrames(tester);
|
||||
}
|
||||
|
||||
Future<void> _pumpRequestFrames(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
class _MusicBrowseHarness {
|
||||
final bool failFirstBrowse;
|
||||
var browseRequestCount = 0;
|
||||
late final JellyfinClient client;
|
||||
late final MultiServerManager manager;
|
||||
late final MultiServerProvider provider;
|
||||
|
||||
_MusicBrowseHarness({this.failFirstBrowse = false}) {
|
||||
client = JellyfinClient.forTesting(
|
||||
connection: testJellyfinConnection(machineId: 'music-server'),
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/Items/Filters') {
|
||||
return http.Response(
|
||||
jsonEncode({'Genres': const [], 'OfficialRatings': const [], 'Tags': const [], 'Years': const []}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (request.url.path == '/Artists/AlbumArtists') {
|
||||
browseRequestCount++;
|
||||
if (failFirstBrowse && browseRequestCount == 1) {
|
||||
return http.Response('gateway timeout', 504);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': const [
|
||||
{'Id': 'artist-1', 'Name': 'Artist One', 'Type': 'MusicArtist'},
|
||||
],
|
||||
'TotalRecordCount': 1,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
}),
|
||||
);
|
||||
manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||
provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
provider.dispose();
|
||||
manager.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1754,6 +1754,40 @@ void main() {
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||
});
|
||||
|
||||
test('music browse and detail requests use leaf-appropriate fields', () async {
|
||||
final captured = <Uri>[];
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
captured.add(req.url);
|
||||
return http.Response(
|
||||
jsonEncode({'Items': const [], 'TotalRecordCount': 0}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.album, offset: 0, limit: 20));
|
||||
await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.track, offset: 0, limit: 20));
|
||||
await scoped.fetchArtistAlbums('artist-1');
|
||||
await scoped.fetchAlbumTracks('album-1');
|
||||
|
||||
final albumBrowse = captured[0].queryParameters;
|
||||
final trackBrowse = captured[1].queryParameters;
|
||||
final artistAlbums = captured[2].queryParameters;
|
||||
final albumTracks = captured[3].queryParameters;
|
||||
|
||||
expect(albumBrowse['Fields'], 'PremiereDate,OriginalTitle,SortName');
|
||||
expect(albumBrowse['EnableUserData'], 'false');
|
||||
expect(trackBrowse['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
|
||||
expect(trackBrowse.containsKey('EnableUserData'), isFalse);
|
||||
expect(artistAlbums['Fields'], 'PremiereDate,OriginalTitle,SortName');
|
||||
expect(artistAlbums['EnableUserData'], 'false');
|
||||
expect(albumTracks['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
|
||||
});
|
||||
|
||||
test('fetchLibraryFiltersWithValues adds unwatched boolean filter', () async {
|
||||
Uri? captured;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
@@ -1775,6 +1809,7 @@ void main() {
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final result = await scoped.fetchLibraryFiltersWithValues('lib-1');
|
||||
final musicResult = await scoped.fetchLibraryFiltersWithValues('lib-1', libraryKind: MediaKind.artist);
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured!.path, '/Items/Filters');
|
||||
@@ -1791,6 +1826,7 @@ void main() {
|
||||
expect(result.filters.first.filterType, 'boolean');
|
||||
expect(result.filters.first.key, 'jellyfin:unwatched');
|
||||
expect(result.filters.first.title, 'Unwatched');
|
||||
expect(musicResult.filters.first.title, 'Unplayed');
|
||||
expect(result.filters[1].filterType, 'boolean');
|
||||
expect(result.filters[1].key, 'jellyfin:favorite');
|
||||
expect(result.filters[1].title, 'Favorites');
|
||||
|
||||
@@ -99,6 +99,10 @@ void main() {
|
||||
item.thumbPath,
|
||||
'/Items/425f9ab168792a3be733d169b770853f/Images/Primary?tag=1ed1281fd45ff9b8b5ad62b6f6a34d17',
|
||||
);
|
||||
expect(
|
||||
item.posterThumbFallback(),
|
||||
'/Items/27511f928761c3f5c080d43d6799ea09/Images/Primary?tag=233dccb8ad84d8ac473dbffb86c35e6c',
|
||||
);
|
||||
expect(item.durationMs, 12000);
|
||||
});
|
||||
|
||||
@@ -129,14 +133,15 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('track without embedded art or album image tag keeps a null thumb', () {
|
||||
test('track without image tags still tries the album primary endpoint', () {
|
||||
final json = _audioJson()
|
||||
..['ImageTags'] = <String, dynamic>{}
|
||||
..remove('AlbumPrimaryImageTag');
|
||||
|
||||
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
expect(item.thumbPath, isNull);
|
||||
expect(item.thumbPath, '/Items/27511f928761c3f5c080d43d6799ea09/Images/Primary');
|
||||
expect(item.posterThumbFallback(), isNull);
|
||||
});
|
||||
|
||||
test('maps a MusicAlbum with artist hierarchy and track counts', () {
|
||||
|
||||
@@ -138,6 +138,33 @@ void main() {
|
||||
final imageBox = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first;
|
||||
expect(tester.getSize(imageBox), Size(base, base));
|
||||
});
|
||||
|
||||
testWidgets('track artwork failure falls back to album artwork', (tester) async {
|
||||
const trackArtwork = 'https://media.example/track.jpg';
|
||||
const albumArtwork = 'https://media.example/album.jpg';
|
||||
final item = testMediaItem(
|
||||
id: 'track-with-fallback',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.track,
|
||||
title: 'Track',
|
||||
thumbPath: trackArtwork,
|
||||
parentThumbPath: albumArtwork,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_TestApp(child: MediaCard(item: item, width: 200, height: 194, forceGridMode: true, isOffline: true)),
|
||||
);
|
||||
|
||||
final primaryFinder = find.descendant(of: find.byType(MediaCard), matching: find.byType(OptimizedMediaImage)).first;
|
||||
final primary = tester.widget<OptimizedMediaImage>(primaryFinder);
|
||||
expect(primary.imagePath, trackArtwork);
|
||||
expect(primary.errorWidget, isNotNull);
|
||||
|
||||
final fallback = primary.errorWidget!(tester.element(primaryFinder), trackArtwork, StateError('decode failed'));
|
||||
expect(fallback, isA<OptimizedMediaImage>());
|
||||
expect((fallback as OptimizedMediaImage).imagePath, albumArtwork);
|
||||
expect(fallback.imageType, ImageType.square);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user