fix(media): serialize browsing and metadata mutations
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/plex/plex_metadata_preferences.dart';
|
||||
import 'package:plezy/services/plex_mappers.dart';
|
||||
|
||||
void main() {
|
||||
group('PlexMetadataPreferences', () {
|
||||
test('canonicalizes booleans while preserving string, numeric, and empty preference values', () {
|
||||
final preferences = PlexMetadataPreferences.fromMediaContainer({
|
||||
'Metadata': [
|
||||
{
|
||||
'Setting': [
|
||||
{'id': 'episodeSort', 'value': 1},
|
||||
{'id': 'useOriginalTitle', 'value': true},
|
||||
{'id': 'useLocalArtwork', 'value': false},
|
||||
{'id': 'languageOverride', 'value': ''},
|
||||
{'id': 'showOrdering', 'value': 'tvdbAbsolute'},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(preferences.values, {
|
||||
'episodeSort': '1',
|
||||
'useOriginalTitle': '1',
|
||||
'useLocalArtwork': '0',
|
||||
'languageOverride': '',
|
||||
'showOrdering': 'tvdbAbsolute',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns no preferences for absent, null, or malformed metadata envelopes', () {
|
||||
final containers = <Map<String, dynamic>?>[
|
||||
null,
|
||||
const {},
|
||||
const {'Metadata': null},
|
||||
const {'Metadata': 'not-an-object'},
|
||||
const {
|
||||
'Metadata': [null, 'not-an-object'],
|
||||
},
|
||||
];
|
||||
|
||||
for (final container in containers) {
|
||||
expect(PlexMetadataPreferences.fromMediaContainer(container).values, isEmpty, reason: '$container');
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores malformed setting siblings without discarding valid rows', () {
|
||||
final preferences = PlexMetadataPreferences.fromMediaContainer({
|
||||
'Metadata': {
|
||||
'Setting': [
|
||||
null,
|
||||
'not-an-object',
|
||||
{'id': 7, 'value': 'wrong-id-type'},
|
||||
{'id': 'missing-value'},
|
||||
{'id': 'null-value', 'value': null},
|
||||
{
|
||||
'id': 'object-value',
|
||||
'value': {'nested': true},
|
||||
},
|
||||
{'id': 'valid', 'value': -3},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(preferences.values, {'valid': '-3'});
|
||||
});
|
||||
|
||||
test('accepts the single-setting object shape', () {
|
||||
final preferences = PlexMetadataPreferences.fromMediaContainer({
|
||||
'Metadata': {
|
||||
'Setting': {'id': 'subtitleMode', 'value': 2},
|
||||
},
|
||||
});
|
||||
|
||||
expect(preferences.values, {'subtitleMode': '2'});
|
||||
});
|
||||
});
|
||||
|
||||
test('Plex metadata DTO preserves original title and flexible rating values', () {
|
||||
final metadata = PlexMetadataDto.fromJson({
|
||||
'ratingKey': 'movie-1',
|
||||
'originalTitle': 'Le titre original',
|
||||
'rating': '8.2',
|
||||
'audienceRating': 8,
|
||||
'userRating': '9.5',
|
||||
});
|
||||
|
||||
expect(metadata.originalTitle, 'Le titre original');
|
||||
expect(metadata.rating, 8.2);
|
||||
expect(metadata.audienceRating, 8.0);
|
||||
expect(metadata.userRating, 9.5);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:plezy/screens/libraries/tabs/base_library_tab.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
|
||||
const _library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies');
|
||||
const _libraryB = MediaLibrary(id: '2', backend: MediaBackend.plex, title: 'Shows');
|
||||
|
||||
class _ProbeTab extends BaseLibraryTab<String> {
|
||||
const _ProbeTab({super.key, required this.loadedItems, required super.onBack})
|
||||
@@ -42,6 +44,33 @@ class _ProbeTabState extends BaseLibraryTabState<String, _ProbeTab> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ControlledTab extends BaseLibraryTab<String> {
|
||||
const _ControlledTab({super.key, required super.library, required this.load, super.onDataLoaded})
|
||||
: super(suppressAutoFocus: true);
|
||||
|
||||
final Future<List<String>> Function(MediaLibrary library) load;
|
||||
|
||||
@override
|
||||
State<_ControlledTab> createState() => _ControlledTabState();
|
||||
}
|
||||
|
||||
class _ControlledTabState extends BaseLibraryTabState<String, _ControlledTab> {
|
||||
@override
|
||||
Future<List<String>> loadData() => widget.load(widget.library);
|
||||
|
||||
@override
|
||||
Widget buildContent(List<String> items) => ListView(children: items.map(Text.new).toList());
|
||||
|
||||
@override
|
||||
IconData get emptyIcon => Icons.inbox_rounded;
|
||||
|
||||
@override
|
||||
String get emptyMessage => 'Empty';
|
||||
|
||||
@override
|
||||
String get errorContext => 'controlled';
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -88,4 +117,86 @@ void main() {
|
||||
expect(fallbackCalls, 0);
|
||||
expect(state.focusFirstItemCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('retained state rejects a completion from the previous library', (tester) async {
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
final a = Completer<List<String>>();
|
||||
final b = Completer<List<String>>();
|
||||
var loadedCalls = 0;
|
||||
|
||||
Future<List<String>> load(MediaLibrary library) => library.id == _library.id ? a.future : b.future;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: _library, load: load, onDataLoaded: () => loadedCalls++),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: _libraryB, load: load, onDataLoaded: () => loadedCalls++),
|
||||
),
|
||||
);
|
||||
|
||||
b.complete(const ['current B']);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('current B'), findsOneWidget);
|
||||
expect(loadedCalls, 1);
|
||||
|
||||
a.complete(const ['stale A']);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('current B'), findsOneWidget);
|
||||
expect(find.text('stale A'), findsNothing);
|
||||
expect(loadedCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('retained state rejects a stale failure after current success', (tester) async {
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
final a = Completer<List<String>>();
|
||||
final b = Completer<List<String>>();
|
||||
|
||||
Future<List<String>> load(MediaLibrary library) => library.id == _library.id ? a.future : b.future;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: _library, load: load),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: _libraryB, load: load),
|
||||
),
|
||||
);
|
||||
|
||||
b.complete(const ['current B']);
|
||||
await tester.pump();
|
||||
a.completeError(StateError('stale failure'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('current B'), findsOneWidget);
|
||||
expect(find.textContaining('stale failure'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('newest same-library refresh owns the committed result', (tester) async {
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
final loads = [Completer<List<String>>(), Completer<List<String>>()];
|
||||
var request = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: _library, load: (_) => loads[request++].future),
|
||||
),
|
||||
);
|
||||
key.currentState!.refresh();
|
||||
|
||||
loads[1].complete(const ['newer']);
|
||||
await tester.pump();
|
||||
expect(find.text('newer'), findsOneWidget);
|
||||
|
||||
loads[0].complete(const ['older']);
|
||||
await tester.pump();
|
||||
expect(find.text('newer'), findsOneWidget);
|
||||
expect(find.text('older'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_filter.dart';
|
||||
import 'package:plezy/screens/libraries/filters_bottom_sheet.dart';
|
||||
import 'package:plezy/screens/libraries/state_messages.dart';
|
||||
import 'package:plezy/widgets/bottom_sheet_header.dart';
|
||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||
|
||||
final _filters = [
|
||||
MediaFilter(filter: 'genre', filterType: 'string', key: 'genre', title: 'Genre', type: 'filter'),
|
||||
MediaFilter(filter: 'studio', filterType: 'string', key: 'studio', title: 'Studio', type: 'filter'),
|
||||
];
|
||||
|
||||
MediaFilterValue _value(String key, String title) => MediaFilterValue(key: key, title: title);
|
||||
|
||||
void main() {
|
||||
testWidgets('filter switch rejects an obsolete success and its presentation effects', (tester) async {
|
||||
final requests = _FilterRequests();
|
||||
final harness = await _pumpSheet(tester, loader: requests.load);
|
||||
|
||||
await _openFilter(tester, 'Genre');
|
||||
await _goBack(tester);
|
||||
await _openFilter(tester, 'Studio');
|
||||
|
||||
requests.request('studio').complete([_value('studio-b', 'Current Studio')]);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Current Studio'), findsOneWidget);
|
||||
|
||||
requests.request('genre').complete([_value('genre-a', 'Obsolete Genre')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Current Studio'), findsOneWidget);
|
||||
expect(find.text('Obsolete Genre'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('same-filter reopen rejects the first request completion', (tester) async {
|
||||
final requests = _FilterRequests();
|
||||
final harness = await _pumpSheet(tester, loader: requests.load);
|
||||
|
||||
await _openFilter(tester, 'Genre');
|
||||
await _goBack(tester);
|
||||
await _openFilter(tester, 'Genre');
|
||||
|
||||
requests.request('genre', 1).complete([_value('new', 'New Genre')]);
|
||||
await tester.pumpAndSettle();
|
||||
requests.request('genre').complete([_value('old', 'Old Genre')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('New Genre'), findsOneWidget);
|
||||
expect(find.text('Old Genre'), findsNothing);
|
||||
harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('stale failure cannot replace a newer successful value list', (tester) async {
|
||||
final requests = _FilterRequests();
|
||||
final harness = await _pumpSheet(tester, loader: requests.load);
|
||||
|
||||
await _openFilter(tester, 'Genre');
|
||||
await _goBack(tester);
|
||||
await _openFilter(tester, 'Studio');
|
||||
requests.request('studio').complete([_value('current', 'Current Studio')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
requests.request('genre').completeError(StateError('obsolete failure'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ErrorStateWidget), findsNothing);
|
||||
expect(find.text('Current Studio'), findsOneWidget);
|
||||
harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('library replacement retires the old owner request', (tester) async {
|
||||
final requests = _FilterRequests();
|
||||
final harness = await _pumpSheet(tester, loader: requests.load);
|
||||
|
||||
await _openFilter(tester, 'Genre');
|
||||
harness.config.value = harness.config.value.copyWith(libraryKey: 'library-b');
|
||||
await tester.pump();
|
||||
|
||||
requests.request('genre').complete([_value('old-owner', 'Old Library Genre')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Old Library Genre'), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.text('Filters'), findsOneWidget);
|
||||
harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('back then clear retires a loading request before closing', (tester) async {
|
||||
final requests = _FilterRequests();
|
||||
final applied = <Map<String, String>>[];
|
||||
final harness = await _pumpSheet(
|
||||
tester,
|
||||
loader: requests.load,
|
||||
selectedFilters: const {'studio': 'selected'},
|
||||
onChanged: applied.add,
|
||||
);
|
||||
|
||||
await _openFilter(tester, 'Genre');
|
||||
await _goBack(tester);
|
||||
await tester.tap(find.text('Clear All'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(applied, hasLength(1));
|
||||
expect(applied.single, isEmpty);
|
||||
expect(find.byType(FiltersBottomSheet), findsNothing);
|
||||
|
||||
requests.request('genre').complete([_value('late', 'Late Genre')]);
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('missing selected value is preserved until explicit user action', (tester) async {
|
||||
final requests = _FilterRequests();
|
||||
final applied = <Map<String, String>>[];
|
||||
final harness = await _pumpSheet(
|
||||
tester,
|
||||
loader: requests.load,
|
||||
selectedFilters: const {'genre': 'missing'},
|
||||
onChanged: applied.add,
|
||||
);
|
||||
|
||||
await _openFilter(tester, 'Genre');
|
||||
requests.request('genre').complete([_value('available', 'Available Genre')]);
|
||||
await tester.pumpAndSettle();
|
||||
await _goBack(tester);
|
||||
|
||||
expect(find.text('Clear All'), findsOneWidget);
|
||||
expect(applied, isEmpty);
|
||||
harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('load failure has retry state while empty success remains selectable', (tester) async {
|
||||
final requests = _FilterRequests();
|
||||
final harness = await _pumpSheet(tester, loader: requests.load);
|
||||
|
||||
await _openFilter(tester, 'Genre');
|
||||
requests.request('genre').completeError(StateError('temporary failure'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ErrorStateWidget), findsOneWidget);
|
||||
expect(find.text('Retry'), findsOneWidget);
|
||||
expect(find.text('All'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pump();
|
||||
requests.request('genre', 1).complete(const []);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ErrorStateWidget), findsNothing);
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('cached values bypass the lazy loader', (tester) async {
|
||||
var loadCount = 0;
|
||||
final harness = await _pumpSheet(
|
||||
tester,
|
||||
loader: (_) async {
|
||||
loadCount++;
|
||||
return const [];
|
||||
},
|
||||
cachedValues: {
|
||||
'genre': [_value('cached', 'Cached Genre')],
|
||||
},
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Genre'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Cached Genre'), findsOneWidget);
|
||||
expect(loadCount, 0);
|
||||
harness.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<_SheetHarness> _pumpSheet(
|
||||
WidgetTester tester, {
|
||||
required Future<List<MediaFilterValue>> Function(MediaFilter filter) loader,
|
||||
Map<String, String> selectedFilters = const {},
|
||||
Map<String, List<MediaFilterValue>>? cachedValues,
|
||||
ValueChanged<Map<String, String>>? onChanged,
|
||||
}) async {
|
||||
final config = ValueNotifier(
|
||||
_SheetConfig(
|
||||
serverId: 'server',
|
||||
libraryKey: 'library-a',
|
||||
selectedFilters: selectedFilters,
|
||||
cachedValues: cachedValues,
|
||||
loader: loader,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: OverlaySheetHost(
|
||||
child: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () {
|
||||
OverlaySheetController.of(context).show<void>(
|
||||
builder: (_) => ValueListenableBuilder(
|
||||
valueListenable: config,
|
||||
builder: (_, value, _) => FiltersBottomSheet(
|
||||
key: const ValueKey('filters-sheet'),
|
||||
filters: _filters,
|
||||
selectedFilters: value.selectedFilters,
|
||||
onFiltersChanged: onChanged ?? (_) {},
|
||||
serverId: value.serverId,
|
||||
libraryKey: value.libraryKey,
|
||||
loadFilterValues: value.loader,
|
||||
cachedValues: value.cachedValues,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
return _SheetHarness(config);
|
||||
}
|
||||
|
||||
Future<void> _openFilter(WidgetTester tester, String title) async {
|
||||
await tester.tap(find.text(title));
|
||||
await tester.pump();
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
}
|
||||
|
||||
Future<void> _goBack(WidgetTester tester) async {
|
||||
final headerRect = tester.getRect(find.byType(BottomSheetHeader));
|
||||
await tester.tapAt(headerRect.centerLeft + const Offset(20, 0));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
class _FilterRequests {
|
||||
final Map<String, List<Completer<List<MediaFilterValue>>>> _requests = {};
|
||||
|
||||
Future<List<MediaFilterValue>> load(MediaFilter filter) {
|
||||
final request = Completer<List<MediaFilterValue>>();
|
||||
_requests.putIfAbsent(filter.filter, () => []).add(request);
|
||||
return request.future;
|
||||
}
|
||||
|
||||
Completer<List<MediaFilterValue>> request(String filter, [int index = 0]) => _requests[filter]![index];
|
||||
}
|
||||
|
||||
class _SheetConfig {
|
||||
const _SheetConfig({
|
||||
required this.serverId,
|
||||
required this.libraryKey,
|
||||
required this.selectedFilters,
|
||||
required this.loader,
|
||||
this.cachedValues,
|
||||
});
|
||||
|
||||
final String serverId;
|
||||
final String libraryKey;
|
||||
final Map<String, String> selectedFilters;
|
||||
final Future<List<MediaFilterValue>> Function(MediaFilter filter) loader;
|
||||
final Map<String, List<MediaFilterValue>>? cachedValues;
|
||||
|
||||
_SheetConfig copyWith({String? libraryKey}) => _SheetConfig(
|
||||
serverId: serverId,
|
||||
libraryKey: libraryKey ?? this.libraryKey,
|
||||
selectedFilters: selectedFilters,
|
||||
loader: loader,
|
||||
cachedValues: cachedValues,
|
||||
);
|
||||
}
|
||||
|
||||
class _SheetHarness {
|
||||
const _SheetHarness(this.config);
|
||||
|
||||
final ValueNotifier<_SheetConfig> config;
|
||||
|
||||
void dispose() => config.dispose();
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.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/mixins/refreshable.dart';
|
||||
import 'package:plezy/providers/hidden_libraries_provider.dart';
|
||||
import 'package:plezy/providers/libraries_provider.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/libraries/libraries_screen.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
import 'package:shared_preferences_platform_interface/types.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
const _libraryA = MediaLibrary(
|
||||
id: 'movies',
|
||||
backend: MediaBackend.plex,
|
||||
title: 'Library A',
|
||||
kind: MediaKind.movie,
|
||||
serverId: 'server',
|
||||
);
|
||||
const _libraryB = MediaLibrary(
|
||||
id: 'shows',
|
||||
backend: MediaBackend.plex,
|
||||
title: 'Library B',
|
||||
kind: MediaKind.show,
|
||||
serverId: 'server',
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
});
|
||||
|
||||
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
|
||||
|
||||
testWidgets('stale saved tab cannot replace the current library tab', (tester) async {
|
||||
final preferences = _GatedPreferences({
|
||||
'selected_library_key': _libraryB.globalKey,
|
||||
'library_tab_${_libraryA.globalKey}': LibraryTabType.playlists.name,
|
||||
'library_tab_${_libraryB.globalKey}': LibraryTabType.browse.name,
|
||||
});
|
||||
final harness = await _Harness.create(preferences);
|
||||
addTearDown(harness.dispose);
|
||||
final selected = <String>[];
|
||||
|
||||
await harness.pump(tester, onLibrarySelected: selected.add);
|
||||
expect(harness.controller(tester).index, 1);
|
||||
|
||||
preferences.blockNextSelectedLibraryWrite(_libraryA.globalKey);
|
||||
final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable;
|
||||
loadable.loadLibraryByKey(_libraryA.globalKey);
|
||||
await preferences.blocked;
|
||||
|
||||
loadable.loadLibraryByKey(_libraryB.globalKey);
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected.last, _libraryB.globalKey);
|
||||
expect(harness.controller(tester).index, 1);
|
||||
|
||||
preferences.release();
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected.last, _libraryB.globalKey);
|
||||
expect(harness.controller(tester).index, 1);
|
||||
});
|
||||
|
||||
testWidgets('restoration applies a saved first tab', (tester) async {
|
||||
final preferences = _GatedPreferences({
|
||||
'selected_library_key': _libraryB.globalKey,
|
||||
'library_tab_${_libraryA.globalKey}': LibraryTabType.recommended.name,
|
||||
'library_tab_${_libraryB.globalKey}': LibraryTabType.browse.name,
|
||||
});
|
||||
final harness = await _Harness.create(preferences);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.pump(tester);
|
||||
expect(harness.controller(tester).index, 1);
|
||||
|
||||
final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable;
|
||||
loadable.loadLibraryByKey(_libraryA.globalKey);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.controller(tester).index, 0);
|
||||
});
|
||||
|
||||
testWidgets('disposal rejects a pending saved-tab continuation', (tester) async {
|
||||
final preferences = _GatedPreferences({
|
||||
'selected_library_key': _libraryB.globalKey,
|
||||
'library_tab_${_libraryA.globalKey}': LibraryTabType.playlists.name,
|
||||
});
|
||||
final harness = await _Harness.create(preferences);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.pump(tester);
|
||||
preferences.blockNextSelectedLibraryWrite(_libraryA.globalKey);
|
||||
final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable;
|
||||
loadable.loadLibraryByKey(_libraryA.globalKey);
|
||||
await preferences.blocked;
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
preferences.release();
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
final class _Harness {
|
||||
_Harness({required this.libraries, required this.hiddenLibraries, required this.multiServer});
|
||||
|
||||
final LibrariesProvider libraries;
|
||||
final HiddenLibrariesProvider hiddenLibraries;
|
||||
final MultiServerProvider multiServer;
|
||||
|
||||
static Future<_Harness> create(_GatedPreferences preferences) async {
|
||||
SharedPreferencesAsyncPlatform.instance = preferences;
|
||||
await SettingsService.getInstance();
|
||||
await StorageService.getInstance();
|
||||
final libraries = LibrariesProvider();
|
||||
await libraries.updateLibraryOrder(const [_libraryA, _libraryB]);
|
||||
final hiddenLibraries = HiddenLibrariesProvider();
|
||||
await hiddenLibraries.ensureInitialized();
|
||||
final manager = MultiServerManager();
|
||||
final multiServer = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer);
|
||||
}
|
||||
|
||||
Future<void> pump(WidgetTester tester, {ValueChanged<String>? onLibrarySelected}) async {
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(value: libraries),
|
||||
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibraries),
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
|
||||
],
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: LibrariesScreen(onLibrarySelected: onLibrarySelected),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
TabController controller(WidgetTester tester) {
|
||||
final dynamic state = tester.state(find.byType(LibrariesScreen));
|
||||
return state.tabController as TabController;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
libraries.dispose();
|
||||
hiddenLibraries.dispose();
|
||||
multiServer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final class _GatedPreferences extends InMemorySharedPreferencesAsync {
|
||||
_GatedPreferences(super.data) : super.withData();
|
||||
|
||||
String? _blockedValue;
|
||||
Completer<void>? _entered;
|
||||
Completer<void>? _release;
|
||||
|
||||
Future<void> get blocked => _entered!.future;
|
||||
|
||||
void blockNextSelectedLibraryWrite(String value) {
|
||||
_blockedValue = value;
|
||||
_entered = Completer<void>();
|
||||
_release = Completer<void>();
|
||||
}
|
||||
|
||||
void release() {
|
||||
final release = _release;
|
||||
if (release != null && !release.isCompleted) release.complete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> setString(String key, String value, SharedPreferencesOptions options) async {
|
||||
final result = await super.setString(key, value, options);
|
||||
if (key.endsWith('selected_library_key') && value == _blockedValue) {
|
||||
_blockedValue = null;
|
||||
_entered!.complete();
|
||||
await _release!.future;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/library_filter_result.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_library.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/media_sort.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/navigation/main_screen_scope.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/libraries/state_messages.dart';
|
||||
import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/focusable_filter_chip.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/media_items.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
await StorageService.getInstance();
|
||||
});
|
||||
|
||||
testWidgets('switching libraries retires the old filter phase before it can start a page load', (tester) async {
|
||||
final sortA = Completer<List<MediaSort>>();
|
||||
final harness = _BrowseHarness(clientA: _BrowseClient('server-a', 'Library A', sortResponse: sortA.future));
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpHarness(tester, harness);
|
||||
expect(harness.clientA.sortRequestCount, 1);
|
||||
expect(harness.clientA.pageRequestCount, 0);
|
||||
|
||||
harness.selectedLibrary.value = harness.libraryB;
|
||||
await _pumpRequestFrames(tester);
|
||||
expect(find.text('Library B'), findsOneWidget);
|
||||
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
|
||||
|
||||
sortA.complete(const []);
|
||||
await _pumpRequestFrames(tester);
|
||||
|
||||
expect(find.text('Library A'), findsNothing);
|
||||
expect(find.text('Library B'), findsOneWidget);
|
||||
expect(harness.clientA.pageRequestCount, 0);
|
||||
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
|
||||
});
|
||||
|
||||
testWidgets('accepted page post-frame effects are rejected after a library replacement', (tester) async {
|
||||
final pageA = Completer<LibraryPage<MediaItem>>();
|
||||
final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future);
|
||||
final harness = _BrowseHarness(clientA: clientA);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpHarness(tester, harness, settle: false);
|
||||
await _pumpUntil(tester, () => clientA.pageRequestCount == 1);
|
||||
|
||||
pageA.complete(_pageFor(clientA, 'Library A'));
|
||||
await tester.idle();
|
||||
expect(harness.loadedLibraries, isEmpty);
|
||||
|
||||
harness.selectedLibrary.value = harness.libraryB;
|
||||
await _pumpRequestFrames(tester);
|
||||
|
||||
expect(find.text('Library A'), findsNothing);
|
||||
expect(find.text('Library B'), findsOneWidget);
|
||||
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
|
||||
});
|
||||
|
||||
testWidgets('unmounting retires pending browse data and callbacks', (tester) async {
|
||||
final pageA = Completer<LibraryPage<MediaItem>>();
|
||||
final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future);
|
||||
final harness = _BrowseHarness(clientA: clientA);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpHarness(tester, harness, settle: false);
|
||||
await _pumpUntil(tester, () => clientA.pageRequestCount == 1);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
|
||||
pageA.complete(_pageFor(clientA, 'Library A'));
|
||||
await tester.pump();
|
||||
|
||||
expect(harness.loadedLibraries, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('retry accepts an empty current page and returns keyboard focus to library chrome', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
|
||||
final emptyPage = Completer<LibraryPage<MediaItem>>();
|
||||
final clientA = _BrowseClient('server-a', 'Library A')
|
||||
..pageResponses.add(() => Future<LibraryPage<MediaItem>>.error(StateError('temporary browse failure')))
|
||||
..pageResponses.add(() => emptyPage.future);
|
||||
final harness = _BrowseHarness(clientA: clientA);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpHarness(tester, harness);
|
||||
expect(find.byType(ErrorStateWidget), findsOneWidget);
|
||||
|
||||
final groupingChip = tester.widget<FocusableFilterChip>(find.byType(FocusableFilterChip).first);
|
||||
groupingChip.focusNode!.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await _pumpUntil(tester, () => clientA.pageRequestCount == 2);
|
||||
|
||||
emptyPage.complete(const LibraryPage<MediaItem>(items: [], totalCount: 0));
|
||||
await _pumpRequestFrames(tester);
|
||||
|
||||
expect(find.byType(ErrorStateWidget), findsNothing);
|
||||
expect(find.byType(EmptyStateWidget), findsOneWidget);
|
||||
expect(harness.loadedLibraries, [harness.libraryA.globalKey]);
|
||||
expect(groupingChip.focusNode!.hasFocus, isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
addTearDown(() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
tester.view.resetPhysicalSize();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: harness.provider,
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: MainScreenFocusScope(
|
||||
focusSidebar: () {},
|
||||
focusContent: () {},
|
||||
isSidebarFocused: false,
|
||||
sideNavigationWidth: 0,
|
||||
child: Scaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)),
|
||||
),
|
||||
],
|
||||
body: ValueListenableBuilder<MediaLibrary>(
|
||||
valueListenable: harness.selectedLibrary,
|
||||
builder: (context, library, _) => LibraryBrowseTab(
|
||||
library: library,
|
||||
canGroupByFolders: true,
|
||||
isActive: true,
|
||||
onDataLoaded: () => harness.loadedLibraries.add(library.globalKey),
|
||||
onBack: () => harness.chromeFocusRequests++,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (settle) await _pumpRequestFrames(tester);
|
||||
}
|
||||
|
||||
Future<void> _pumpRequestFrames(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
|
||||
for (var i = 0; i < 20 && !condition(); i++) {
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
}
|
||||
expect(condition(), isTrue);
|
||||
}
|
||||
|
||||
LibraryPage<MediaItem> _pageFor(_BrowseClient client, String title) {
|
||||
return LibraryPage<MediaItem>(
|
||||
items: [
|
||||
testMediaItem(
|
||||
id: '${client.serverId.value}-item',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.artist,
|
||||
title: title,
|
||||
serverId: client.serverId.value,
|
||||
serverName: client.serverName,
|
||||
),
|
||||
],
|
||||
totalCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
class _BrowseHarness {
|
||||
final _BrowseClient clientA;
|
||||
late final _BrowseClient clientB;
|
||||
late final MediaLibrary libraryA;
|
||||
late final MediaLibrary libraryB;
|
||||
late final ValueNotifier<MediaLibrary> selectedLibrary;
|
||||
late final MultiServerManager manager;
|
||||
late final MultiServerProvider provider;
|
||||
final List<String> loadedLibraries = [];
|
||||
var chromeFocusRequests = 0;
|
||||
|
||||
_BrowseHarness({required this.clientA}) {
|
||||
clientB = _BrowseClient('server-b', 'Library B');
|
||||
libraryA = _libraryFor(clientA);
|
||||
libraryB = _libraryFor(clientB);
|
||||
selectedLibrary = ValueNotifier<MediaLibrary>(libraryA);
|
||||
manager = MultiServerManager()
|
||||
..debugRegisterClientForTesting(clientA)
|
||||
..debugRegisterClientForTesting(clientB);
|
||||
provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
}
|
||||
|
||||
MediaLibrary _libraryFor(_BrowseClient client) {
|
||||
return MediaLibrary(
|
||||
id: '${client.serverId.value}-library',
|
||||
backend: MediaBackend.jellyfin,
|
||||
title: client.itemTitle,
|
||||
kind: MediaKind.artist,
|
||||
serverId: client.serverId,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
selectedLibrary.dispose();
|
||||
provider.dispose();
|
||||
manager.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _BrowseClient implements MediaServerClient {
|
||||
@override
|
||||
final ServerId serverId;
|
||||
final String itemTitle;
|
||||
final Future<List<MediaSort>>? sortResponse;
|
||||
final Queue<Future<LibraryPage<MediaItem>> Function()> pageResponses = Queue();
|
||||
var sortRequestCount = 0;
|
||||
var pageRequestCount = 0;
|
||||
|
||||
_BrowseClient(String serverId, this.itemTitle, {this.sortResponse}) : serverId = ServerId(serverId);
|
||||
|
||||
@override
|
||||
String get serverName => itemTitle;
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.jellyfin;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
|
||||
|
||||
@override
|
||||
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType}) {
|
||||
sortRequestCount++;
|
||||
return sortResponse ?? Future.value(const []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async {
|
||||
return LibraryFilterResult.empty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
|
||||
String libraryId, {
|
||||
required LibraryQuery query,
|
||||
MediaKind? libraryKind,
|
||||
AbortController? abort,
|
||||
}) {
|
||||
pageRequestCount++;
|
||||
if (pageResponses.isNotEmpty) return pageResponses.removeFirst()();
|
||||
return Future.value(_pageFor(this, itemTitle));
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -1,11 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
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:material_symbols_icons/symbols.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/focus/dpad_navigator.dart';
|
||||
import 'package:plezy/focus/dpad_select_long_press_controller.dart';
|
||||
import 'package:plezy/models/livetv_channel.dart';
|
||||
import 'package:plezy/models/livetv_program.dart';
|
||||
import 'package:plezy/screens/livetv/tabs/guide_tab.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _selectDown = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.enter,
|
||||
@@ -28,7 +47,16 @@ LiveTvProgram _program({String ratingKey = 'program/42', int beginsAt = 1_800_00
|
||||
);
|
||||
|
||||
void main() {
|
||||
tearDown(SelectKeyUpSuppressor.clearSuppression);
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() => initializeDateFormatting('en'));
|
||||
setUp(() {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
});
|
||||
tearDown(() {
|
||||
SelectKeyUpSuppressor.clearSuppression();
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
test('SELECT hold survives equivalent fresh guide objects and opens details once', () {
|
||||
fakeAsync((async) {
|
||||
@@ -93,4 +121,222 @@ void main() {
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('superseded guide load keeps one interval and cannot replace current programs', (tester) async {
|
||||
final harness = _GuideHarness.twoServers();
|
||||
addTearDown(harness.dispose);
|
||||
await harness.pump(tester);
|
||||
await harness.completeInitial(tester);
|
||||
|
||||
final rightButton = _rightTimeButton();
|
||||
expect(rightButton, findsOneWidget);
|
||||
await tester.tap(rightButton);
|
||||
await tester.tap(rightButton);
|
||||
|
||||
expect(harness.serverA.schedule.requests, hasLength(3));
|
||||
final older = harness.serverA.schedule.requests[1];
|
||||
final newer = harness.serverA.schedule.requests[2];
|
||||
expect(older.to.difference(older.from), const Duration(hours: 6));
|
||||
expect(newer.to.difference(newer.from), const Duration(hours: 6));
|
||||
expect(newer.from.difference(older.from), const Duration(hours: 2));
|
||||
|
||||
harness.serverA.schedule.complete(2, 'Current A');
|
||||
await tester.pump();
|
||||
expect(harness.serverB!.schedule.requests, hasLength(2));
|
||||
final currentB = harness.serverB!.schedule.requests[1];
|
||||
expect(currentB.from, newer.from);
|
||||
expect(currentB.to, newer.to);
|
||||
|
||||
harness.serverB!.schedule.complete(1, 'Current B');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Current A'), findsOneWidget);
|
||||
expect(find.text('Current B'), findsOneWidget);
|
||||
|
||||
harness.serverA.schedule.complete(1, 'Obsolete A');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(harness.serverB!.schedule.requests, hasLength(2));
|
||||
expect(find.text('Obsolete A'), findsNothing);
|
||||
expect(find.text('Current A'), findsOneWidget);
|
||||
expect(find.text('Current B'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('obsolete completion cannot clear loading owned by a newer guide request', (tester) async {
|
||||
final harness = _GuideHarness.oneServer();
|
||||
addTearDown(harness.dispose);
|
||||
await harness.pump(tester);
|
||||
await harness.completeInitial(tester);
|
||||
|
||||
final rightButton = _rightTimeButton();
|
||||
await tester.tap(rightButton);
|
||||
await tester.tap(rightButton);
|
||||
expect(harness.serverA.schedule.requests, hasLength(3));
|
||||
|
||||
harness.serverA.schedule.complete(1, 'Obsolete');
|
||||
await tester.pump();
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.text('Obsolete'), findsNothing);
|
||||
|
||||
harness.serverA.schedule.complete(2, 'Current');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.text('Current'), findsOneWidget);
|
||||
expect(find.text('Obsolete'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Finder _rightTimeButton() {
|
||||
final icon = find.byWidgetPredicate((widget) => widget is AppIcon && widget.icon == Symbols.chevron_right_rounded);
|
||||
return find.ancestor(of: icon, matching: find.byType(IconButton));
|
||||
}
|
||||
|
||||
final class _GuideHarness {
|
||||
_GuideHarness._({required this.serverA, required this.serverB, required this.provider, required this.channels});
|
||||
|
||||
factory _GuideHarness.oneServer() => _GuideHarness._create(includeServerB: false);
|
||||
|
||||
factory _GuideHarness.twoServers() => _GuideHarness._create(includeServerB: true);
|
||||
|
||||
factory _GuideHarness._create({required bool includeServerB}) {
|
||||
final serverA = _FakeMediaServerClient(serverId: 'server-a', stationId: 'station-a');
|
||||
final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null;
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(serverA);
|
||||
if (serverB != null) manager.debugRegisterClientForTesting(serverB);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager))
|
||||
..debugSetLiveTvServersForTesting([
|
||||
LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'),
|
||||
if (serverB != null) LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'),
|
||||
]);
|
||||
return _GuideHarness._(
|
||||
serverA: serverA,
|
||||
serverB: serverB,
|
||||
provider: provider,
|
||||
channels: [
|
||||
_guideChannel(serverId: 'server-a', stationId: 'station-a', callSign: 'A'),
|
||||
if (serverB != null) _guideChannel(serverId: 'server-b', stationId: 'station-b', callSign: 'B'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final _FakeMediaServerClient serverA;
|
||||
final _FakeMediaServerClient? serverB;
|
||||
final MultiServerProvider provider;
|
||||
final List<LiveTvChannel> channels;
|
||||
|
||||
Future<void> pump(WidgetTester tester) 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: Scaffold(body: GuideTab(channels: channels)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(serverA.schedule.requests, hasLength(1));
|
||||
}
|
||||
|
||||
Future<void> completeInitial(WidgetTester tester) async {
|
||||
serverA.schedule.complete(0, 'Initial A');
|
||||
await tester.pump();
|
||||
final serverB = this.serverB;
|
||||
if (serverB != null) {
|
||||
expect(serverB.schedule.requests, hasLength(1));
|
||||
serverB.schedule.complete(0, 'Initial B');
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Initial A'), findsOneWidget);
|
||||
if (serverB != null) expect(find.text('Initial B'), findsOneWidget);
|
||||
}
|
||||
|
||||
void dispose() => provider.dispose();
|
||||
}
|
||||
|
||||
LiveTvChannel _guideChannel({required String serverId, required String stationId, required String callSign}) =>
|
||||
LiveTvChannel(
|
||||
key: 'channel-$stationId',
|
||||
identifier: stationId,
|
||||
callSign: callSign,
|
||||
serverId: serverId,
|
||||
liveDvrKey: 'dvr-$serverId',
|
||||
);
|
||||
|
||||
final class _FakeMediaServerClient implements MediaServerClient {
|
||||
_FakeMediaServerClient({required String serverId, required String stationId})
|
||||
: serverId = ServerId(serverId),
|
||||
schedule = _ControllableLiveTvSupport(serverId: serverId, stationId: stationId);
|
||||
|
||||
@override
|
||||
final ServerId serverId;
|
||||
final _ControllableLiveTvSupport schedule;
|
||||
|
||||
@override
|
||||
LiveTvSupport get liveTv => schedule;
|
||||
|
||||
@override
|
||||
String get serverName => serverId.value;
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
final class _ControllableLiveTvSupport implements LiveTvSupport {
|
||||
_ControllableLiveTvSupport({required this.serverId, required this.stationId});
|
||||
|
||||
final String serverId;
|
||||
final String stationId;
|
||||
final List<_ScheduleRequest> requests = [];
|
||||
|
||||
@override
|
||||
LiveTvDvrSupport? get dvr => null;
|
||||
|
||||
@override
|
||||
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to}) {
|
||||
final request = _ScheduleRequest(from: from!, to: to!);
|
||||
requests.add(request);
|
||||
return request.completer.future;
|
||||
}
|
||||
|
||||
void complete(int index, String title) {
|
||||
final request = requests[index];
|
||||
final beginsAt = request.from.millisecondsSinceEpoch ~/ 1000 + 600;
|
||||
request.completer.complete([
|
||||
LiveTvProgram(
|
||||
ratingKey: '$serverId-$index',
|
||||
title: title,
|
||||
beginsAt: beginsAt,
|
||||
endsAt: beginsAt + 3600,
|
||||
channelIdentifier: stationId,
|
||||
serverId: serverId,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
final class _ScheduleRequest {
|
||||
_ScheduleRequest({required this.from, required this.to});
|
||||
|
||||
final DateTime from;
|
||||
final DateTime to;
|
||||
final Completer<List<LiveTvProgram>> completer = Completer<List<LiveTvProgram>>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.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/media_grab_operation.dart';
|
||||
import 'package:plezy/models/media_subscription.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/livetv/tabs/recordings_tab.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:provider/provider.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
|
||||
testWidgets('coalesces concurrent refreshes and commits completed loads in order', (tester) async {
|
||||
final dvr = _ControllableDvr();
|
||||
final client = _FakeMediaServerClient(dvr);
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager))
|
||||
..debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr')]);
|
||||
addTearDown(provider.dispose);
|
||||
final tabKey = GlobalKey<RecordingsTabState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: InputModeTracker(
|
||||
child: ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(body: RecordingsTab(key: tabKey)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(dvr.grabRequests, hasLength(1));
|
||||
final queuedRefresh = tabKey.currentState!.reload();
|
||||
final coalescedRefresh = tabKey.currentState!.reload();
|
||||
expect(identical(queuedRefresh, coalescedRefresh), isTrue);
|
||||
expect(dvr.grabRequests, hasLength(1));
|
||||
|
||||
dvr.completeGrabs(0, const []);
|
||||
await tester.pump();
|
||||
dvr.completeRules(0, const [_rule]);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(_rule.title!), findsOneWidget);
|
||||
expect(dvr.grabRequests, hasLength(2));
|
||||
|
||||
await tester.pump(const Duration(seconds: 30));
|
||||
final stillCoalesced = tabKey.currentState!.reload();
|
||||
expect(identical(queuedRefresh, stillCoalesced), isTrue);
|
||||
expect(dvr.grabRequests, hasLength(2));
|
||||
|
||||
dvr.completeGrabs(1, const []);
|
||||
await tester.pump();
|
||||
dvr.completeRules(1, const [_rule]);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(_rule.title!), findsOneWidget);
|
||||
expect(dvr.grabRequests, hasLength(3));
|
||||
|
||||
dvr.completeGrabs(2, const []);
|
||||
await tester.pump();
|
||||
dvr.completeRules(2, const []);
|
||||
await queuedRefresh;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(dvr.grabRequests, hasLength(3));
|
||||
expect(dvr.ruleRequests, hasLength(3));
|
||||
expect(find.text(_rule.title!), findsNothing);
|
||||
expect(find.text(t.liveTv.noScheduledRecordings), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
const _rule = MediaSubscription(key: 'rule-1', type: 2, title: 'Obsolete series rule');
|
||||
|
||||
final class _FakeMediaServerClient implements MediaServerClient {
|
||||
_FakeMediaServerClient(this.dvr);
|
||||
|
||||
final _ControllableDvr dvr;
|
||||
|
||||
@override
|
||||
ServerId get serverId => ServerId('server-a');
|
||||
|
||||
@override
|
||||
String get serverName => 'DVR server';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true, liveTvDvr: true);
|
||||
|
||||
@override
|
||||
LiveTvSupport get liveTv => _FakeLiveTvSupport(dvr);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
final class _FakeLiveTvSupport implements LiveTvSupport {
|
||||
_FakeLiveTvSupport(this.dvr);
|
||||
|
||||
@override
|
||||
final LiveTvDvrSupport dvr;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
final class _ControllableDvr implements LiveTvDvrSupport {
|
||||
final List<Completer<List<MediaGrabOperation>>> grabRequests = [];
|
||||
final List<Completer<List<MediaSubscription>>> ruleRequests = [];
|
||||
final List<String> deleteCalls = [];
|
||||
|
||||
@override
|
||||
Future<List<MediaGrabOperation>> fetchScheduledRecordings() {
|
||||
final request = Completer<List<MediaGrabOperation>>();
|
||||
grabRequests.add(request);
|
||||
return request.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) {
|
||||
final request = Completer<List<MediaSubscription>>();
|
||||
ruleRequests.add(request);
|
||||
return request.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRecordingRule(String subscriptionId) async {
|
||||
deleteCalls.add(subscriptionId);
|
||||
}
|
||||
|
||||
void completeGrabs(int index, List<MediaGrabOperation> grabs) => grabRequests[index].complete(grabs);
|
||||
|
||||
void completeRules(int index, List<MediaSubscription> rules) => ruleRequests[index].complete(rules);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/focus/focusable_wrapper.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/metadata_edit/metadata_edit_models.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/metadata_edit_screen.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/widgets/dialog_action_button.dart';
|
||||
import 'package:plezy/widgets/focusable_list_tile.dart';
|
||||
import 'package:plezy/widgets/loading_indicator_box.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
testWidgets('immediate choices serialize commits and block edits and route dismissal', (tester) async {
|
||||
final requests = _PlexMetadataRequests();
|
||||
final harness = await _pumpEditor(tester, requests);
|
||||
final first = requests.holdNextPreferenceUpdate();
|
||||
|
||||
await _chooseEpisodeSort(tester, 'Oldest first');
|
||||
await tester.pump();
|
||||
|
||||
expect(requests.preferenceUpdateCalls, 1);
|
||||
expect(find.byType(LoadingIndicatorBox), findsOneWidget);
|
||||
expect(find.byTooltip('Save'), findsNothing);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
await tester.tap(_fieldTile('Episode Sorting'), warnIfMissed: false);
|
||||
await tester.tap(_fieldTile('Keep'), warnIfMissed: false);
|
||||
await tester.pump();
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(requests.preferenceUpdateCalls, 1);
|
||||
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(find.byType(MetadataEditScreen), findsOneWidget);
|
||||
|
||||
first.complete(_ok());
|
||||
await tester.pumpAndSettle();
|
||||
expect(_tileText('Episode Sorting', 'Oldest first'), findsOneWidget);
|
||||
|
||||
final second = requests.holdNextPreferenceUpdate();
|
||||
await _chooseEpisodeSort(tester, 'Newest first');
|
||||
await tester.pump();
|
||||
expect(requests.preferenceUpdateCalls, 2);
|
||||
expect(requests.maxConcurrentPreferenceUpdates, 1);
|
||||
|
||||
second.complete(_ok());
|
||||
await tester.pumpAndSettle();
|
||||
expect(_tileText('Episode Sorting', 'Newest first'), findsOneWidget);
|
||||
expect(requests.serverPreferences['episodeSort'], '1');
|
||||
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('boolean preference selection and updates use canonical choice values', (tester) async {
|
||||
final requests = _PlexMetadataRequests();
|
||||
final harness = await _pumpEditor(tester, requests);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _scrollToImmediateChoice(tester, 'Use Original Title');
|
||||
expect(_tileText('Use Original Title', 'Yes'), findsOneWidget);
|
||||
|
||||
await _chooseImmediateChoice(tester, 'Use Original Title', 'No');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_tileText('Use Original Title', 'No'), findsOneWidget);
|
||||
expect(requests.preferenceUpdatePayloads.single['useOriginalTitle'], '0');
|
||||
expect(requests.serverPreferences['useOriginalTitle'], '0');
|
||||
|
||||
await _chooseImmediateChoice(tester, 'Use Original Title', 'Yes');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_tileText('Use Original Title', 'Yes'), findsOneWidget);
|
||||
expect(requests.preferenceUpdatePayloads.last['useOriginalTitle'], '1');
|
||||
expect(requests.preferenceUpdatePayloads.map((payload) => payload['useOriginalTitle']), orderedEquals(['0', '1']));
|
||||
});
|
||||
|
||||
testWidgets('immediate failure rolls back its value and re-enables controls', (tester) async {
|
||||
final requests = _PlexMetadataRequests()..queuePreferenceResponse(_response(503));
|
||||
final harness = await _pumpEditor(tester, requests);
|
||||
|
||||
await _chooseEpisodeSort(tester, 'Oldest first');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_tileText('Episode Sorting', 'Library default'), findsOneWidget);
|
||||
expect(find.text('Failed to update metadata'), findsOneWidget);
|
||||
expect(find.byType(LoadingIndicatorBox), findsNothing);
|
||||
|
||||
await _chooseEpisodeSort(tester, 'Newest first');
|
||||
await tester.pumpAndSettle();
|
||||
expect(_tileText('Episode Sorting', 'Newest first'), findsOneWidget);
|
||||
expect(requests.preferenceUpdateCalls, 2);
|
||||
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('final save rejects duplicate submit and back then recovers after failure', (tester) async {
|
||||
final requests = _PlexMetadataRequests();
|
||||
final harness = await _pumpEditor(tester, requests);
|
||||
await _editTitle(tester, 'Updated title');
|
||||
final save = requests.holdNextMetadataUpdate();
|
||||
|
||||
final saveAction = find.byTooltip('Save');
|
||||
await tester.tap(saveAction);
|
||||
await tester.tap(saveAction);
|
||||
await tester.pump();
|
||||
|
||||
expect(requests.metadataUpdateCalls, 1);
|
||||
expect(find.byTooltip('Save'), findsNothing);
|
||||
await tester.tap(_fieldTile('Title'), warnIfMissed: false);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.byType(MetadataEditScreen), findsOneWidget);
|
||||
|
||||
save.complete(_response(500));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MetadataEditScreen), findsOneWidget);
|
||||
expect(find.text('Failed to update metadata'), findsOneWidget);
|
||||
expect(_tileText('Title', 'Updated title'), findsOneWidget);
|
||||
expect(find.byTooltip('Save'), findsOneWidget);
|
||||
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('late final save completion cannot pop a replacement media editor', (tester) async {
|
||||
final requests = _PlexMetadataRequests();
|
||||
final harness = await _pumpEditor(tester, requests);
|
||||
await _editTitle(tester, 'Updated first title');
|
||||
final save = requests.holdNextMetadataUpdate();
|
||||
|
||||
await tester.tap(find.byTooltip('Save'));
|
||||
await tester.pump();
|
||||
expect(requests.metadataUpdateCalls, 1);
|
||||
|
||||
harness.metadata.value = _show(id: 'show-2', title: 'Second show');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(_tileText('Title', 'Second show'), findsOneWidget);
|
||||
|
||||
save.complete(_ok());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MetadataEditScreen), findsOneWidget);
|
||||
expect(_tileText('Title', 'Second show'), findsOneWidget);
|
||||
expect(find.text('Metadata updated'), findsNothing);
|
||||
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
testWidgets('artwork mutation blocks cancel, back, actions, and duplicate options until success', (tester) async {
|
||||
final apply = Completer<bool>();
|
||||
final adapter = _ArtworkAdapter()..applyResult = apply.future;
|
||||
final result = _DialogResult();
|
||||
await _pumpArtworkDialog(tester, adapter, result);
|
||||
|
||||
await tester.tap(_artworkOption());
|
||||
await tester.pump();
|
||||
expect(adapter.applyCalls, 1);
|
||||
expect(_dialogButton(tester, 'From URL').onPressed, isNull);
|
||||
expect(_dialogButton(tester, 'Upload File').onPressed, isNull);
|
||||
expect(_dialogButton(tester, 'Cancel').onPressed, isNull);
|
||||
|
||||
await tester.tap(find.text('Cancel'), warnIfMissed: false);
|
||||
await tester.tap(_artworkOption(), warnIfMissed: false);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
|
||||
expect(adapter.applyCalls, 1);
|
||||
expect(result.completions, 0);
|
||||
|
||||
apply.complete(true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ArtworkPickerDialog), findsNothing);
|
||||
expect(result.completions, 1);
|
||||
expect(result.value, isTrue);
|
||||
expect(find.text('Artwork updated'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('upload failure keeps artwork dialog mounted and restores cancellation', (tester) async {
|
||||
final picker = _FakeFilePicker()
|
||||
..queueResult(
|
||||
FilePickerResult([
|
||||
PlatformFile(name: 'poster.png', size: 3, bytes: Uint8List.fromList([1, 2, 3])),
|
||||
]),
|
||||
);
|
||||
FilePicker.platform = picker;
|
||||
addTearDown(() => FilePicker.platform = _FakeFilePicker());
|
||||
|
||||
final adapter = _ArtworkAdapter()..uploadResult = Future<bool>.value(false);
|
||||
final result = _DialogResult();
|
||||
await _pumpArtworkDialog(tester, adapter, result);
|
||||
|
||||
await tester.tap(find.text('Upload File'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.uploadCalls, 1);
|
||||
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
|
||||
expect(find.text('Failed to update artwork'), findsOneWidget);
|
||||
expect(_dialogButton(tester, 'Cancel').onPressed, isNotNull);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(result.completions, 1);
|
||||
expect(result.value, isNull);
|
||||
});
|
||||
|
||||
testWidgets('picker cancellation and stale picker completion never start an upload', (tester) async {
|
||||
final picker = _FakeFilePicker()..queueResult(null);
|
||||
FilePicker.platform = picker;
|
||||
addTearDown(() => FilePicker.platform = _FakeFilePicker());
|
||||
|
||||
final adapter = _ArtworkAdapter();
|
||||
final firstResult = _DialogResult();
|
||||
await _pumpArtworkDialog(tester, adapter, firstResult);
|
||||
|
||||
await tester.tap(find.text('Upload File'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(adapter.uploadCalls, 0);
|
||||
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final pendingPicker = Completer<FilePickerResult?>();
|
||||
picker.queueFuture(pendingPicker.future);
|
||||
final staleResult = _DialogResult();
|
||||
await _pumpArtworkDialog(tester, adapter, staleResult);
|
||||
await tester.tap(find.text('Upload File'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
pendingPicker.complete(
|
||||
FilePickerResult([
|
||||
PlatformFile(name: 'stale.png', size: 1, bytes: Uint8List.fromList([1])),
|
||||
]),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.uploadCalls, 0);
|
||||
expect(staleResult.completions, 1);
|
||||
expect(staleResult.value, isNull);
|
||||
expect(find.text('Artwork updated'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Finder _fieldTile(String label) => find.widgetWithText(FocusableListTile, label);
|
||||
|
||||
Finder _tileText(String label, String value) {
|
||||
return find.descendant(of: _fieldTile(label), matching: find.text(value));
|
||||
}
|
||||
|
||||
Finder _artworkOption() {
|
||||
return find.descendant(of: find.byType(GridView), matching: find.byType(FocusableWrapper)).first;
|
||||
}
|
||||
|
||||
DialogActionButton _dialogButton(WidgetTester tester, String label) {
|
||||
return tester.widget<DialogActionButton>(find.widgetWithText(DialogActionButton, label));
|
||||
}
|
||||
|
||||
Future<void> _chooseEpisodeSort(WidgetTester tester, String option) {
|
||||
return _chooseImmediateChoice(tester, 'Episode Sorting', option);
|
||||
}
|
||||
|
||||
Future<void> _chooseImmediateChoice(WidgetTester tester, String labelText, String option) async {
|
||||
await _scrollToImmediateChoice(tester, labelText);
|
||||
await tester.tap(_fieldTile(labelText));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(option));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> _scrollToImmediateChoice(WidgetTester tester, String labelText) async {
|
||||
final label = find.text(labelText);
|
||||
await tester.scrollUntilVisible(
|
||||
label,
|
||||
300,
|
||||
scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first,
|
||||
);
|
||||
await Scrollable.ensureVisible(tester.element(label), alignment: 0.5);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _editTitle(WidgetTester tester, String title) async {
|
||||
final tile = _fieldTile('Title');
|
||||
await tester.ensureVisible(tile);
|
||||
await tester.tap(tile);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), title);
|
||||
await tester.tap(find.widgetWithText(DialogActionButton, 'Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_tileText('Title', title), findsOneWidget);
|
||||
}
|
||||
|
||||
Future<_EditorHarness> _pumpEditor(WidgetTester tester, _PlexMetadataRequests requests) async {
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
||||
final database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(database);
|
||||
final client = testPlexClient(serverId: ServerId('server-1'), handler: requests.handle);
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
final metadata = ValueNotifier<MediaItem>(_show());
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ValueListenableBuilder<MediaItem>(
|
||||
valueListenable: metadata,
|
||||
builder: (context, item, _) => MetadataEditScreen(metadata: item),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Open editor'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open editor'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MetadataEditScreen), findsOneWidget);
|
||||
expect(_tileText('Title', 'First show'), findsOneWidget);
|
||||
|
||||
return _EditorHarness(tester: tester, database: database, manager: manager, provider: provider, metadata: metadata);
|
||||
}
|
||||
|
||||
MediaItem _show({String id = 'show-1', String title = 'First show'}) => testMediaItem(
|
||||
id: id,
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
title: title,
|
||||
originalTitle: 'Original $title',
|
||||
summary: 'Summary',
|
||||
libraryId: '1',
|
||||
serverId: 'server-1',
|
||||
thumbPath: '',
|
||||
);
|
||||
|
||||
class _EditorHarness {
|
||||
final WidgetTester tester;
|
||||
final AppDatabase database;
|
||||
final MultiServerManager manager;
|
||||
final MultiServerProvider provider;
|
||||
final ValueNotifier<MediaItem> metadata;
|
||||
|
||||
const _EditorHarness({
|
||||
required this.tester,
|
||||
required this.database,
|
||||
required this.manager,
|
||||
required this.provider,
|
||||
required this.metadata,
|
||||
});
|
||||
|
||||
Future<void> dispose() async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
provider.dispose();
|
||||
manager.dispose();
|
||||
metadata.dispose();
|
||||
await database.close();
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
}
|
||||
}
|
||||
|
||||
class _PlexMetadataRequests {
|
||||
final serverPreferences = <String, Object>{
|
||||
'episodeSort': '-1',
|
||||
'autoDeletionItemPolicyUnwatchedLibrary': '0',
|
||||
'autoDeletionItemPolicyWatchedLibrary': '0',
|
||||
'flattenSeasons': '-1',
|
||||
'showOrdering': '',
|
||||
'languageOverride': '',
|
||||
'useOriginalTitle': true,
|
||||
'audioLanguage': '',
|
||||
'subtitleLanguage': '',
|
||||
'subtitleMode': '-1',
|
||||
};
|
||||
final Queue<Future<http.Response>> _preferenceResponses = Queue();
|
||||
final List<Map<String, String>> preferenceUpdatePayloads = [];
|
||||
final Queue<Future<http.Response>> _metadataResponses = Queue();
|
||||
int preferenceUpdateCalls = 0;
|
||||
int metadataUpdateCalls = 0;
|
||||
int _activePreferenceUpdates = 0;
|
||||
int maxConcurrentPreferenceUpdates = 0;
|
||||
|
||||
Completer<http.Response> holdNextPreferenceUpdate() {
|
||||
final completer = Completer<http.Response>();
|
||||
_preferenceResponses.add(completer.future);
|
||||
return completer;
|
||||
}
|
||||
|
||||
Completer<http.Response> holdNextMetadataUpdate() {
|
||||
final completer = Completer<http.Response>();
|
||||
_metadataResponses.add(completer.future);
|
||||
return completer;
|
||||
}
|
||||
|
||||
void queuePreferenceResponse(http.Response response) {
|
||||
_preferenceResponses.add(Future.value(response));
|
||||
}
|
||||
|
||||
Future<http.Response> handle(http.Request request) async {
|
||||
final path = request.url.path;
|
||||
if (request.method == 'GET' &&
|
||||
path.startsWith('/library/metadata/') &&
|
||||
request.url.queryParameters['includePreferences'] == '1') {
|
||||
final id = path.split('/').last;
|
||||
return _jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
'ratingKey': id,
|
||||
'Setting': [
|
||||
for (final entry in serverPreferences.entries) {'id': entry.key, 'value': entry.value},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method == 'PUT' && path.endsWith('/prefs')) {
|
||||
preferenceUpdateCalls++;
|
||||
preferenceUpdatePayloads.add(Map<String, String>.of(request.url.queryParameters));
|
||||
_activePreferenceUpdates++;
|
||||
if (_activePreferenceUpdates > maxConcurrentPreferenceUpdates) {
|
||||
maxConcurrentPreferenceUpdates = _activePreferenceUpdates;
|
||||
}
|
||||
final response = await (_preferenceResponses.isEmpty ? Future.value(_ok()) : _preferenceResponses.removeFirst());
|
||||
_activePreferenceUpdates--;
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
for (final key in serverPreferences.keys) {
|
||||
final value = request.url.queryParameters[key];
|
||||
if (value != null) serverPreferences[key] = value;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (request.method == 'PUT' && path == '/library/sections/1/all') {
|
||||
metadataUpdateCalls++;
|
||||
return _metadataResponses.isEmpty ? _ok() : await _metadataResponses.removeFirst();
|
||||
}
|
||||
|
||||
return _response(404);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpArtworkDialog(WidgetTester tester, _ArtworkAdapter adapter, _DialogResult result) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
final value = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ArtworkPickerDialog(adapter: adapter, draft: adapter.draft, field: adapter.field),
|
||||
);
|
||||
result.value = value;
|
||||
result.completions++;
|
||||
},
|
||||
child: const Text('Open artwork'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open artwork'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
|
||||
}
|
||||
|
||||
class _DialogResult {
|
||||
int completions = 0;
|
||||
bool? value;
|
||||
}
|
||||
|
||||
class _ArtworkAdapter extends MetadataEditAdapter {
|
||||
final MediaServerClient _client = _NoopMediaClient();
|
||||
late final MetadataEditDraft draft = MetadataEditDraft(
|
||||
sourceItem: _show(),
|
||||
currentItem: _show(),
|
||||
values: {'artwork:posters': ''},
|
||||
);
|
||||
late final MetadataEditField field = const MetadataEditField(
|
||||
id: 'artwork:posters',
|
||||
label: 'Poster',
|
||||
type: MetadataEditFieldType.artwork,
|
||||
saveMode: MetadataEditSaveMode.immediate,
|
||||
artwork: MetadataArtworkConfig(
|
||||
key: 'posters',
|
||||
selectTitle: 'Select Poster',
|
||||
previewWidth: 40,
|
||||
previewHeight: 60,
|
||||
gridColumns: 2,
|
||||
gridAspectRatio: 2 / 3,
|
||||
),
|
||||
);
|
||||
Future<bool> applyResult = Future.value(true);
|
||||
Future<bool> uploadResult = Future.value(true);
|
||||
int applyCalls = 0;
|
||||
int uploadCalls = 0;
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
MediaServerClient get mediaClient => _client;
|
||||
|
||||
@override
|
||||
bool supportsKind(MediaKind kind) => true;
|
||||
|
||||
@override
|
||||
Future<MetadataEditDraft> load(MediaItem item) async => draft;
|
||||
|
||||
@override
|
||||
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) => const [];
|
||||
|
||||
@override
|
||||
Future<bool> save(MetadataEditDraft draft) async => true;
|
||||
|
||||
@override
|
||||
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field) async {
|
||||
return const [MetadataArtworkOption(id: 'option-1', thumbnailPath: '', sourceUrl: 'option-1')];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
||||
applyCalls++;
|
||||
return applyResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> uploadArtwork(MetadataEditDraft draft, MetadataEditField field, List<int> bytes, {String? fileName}) {
|
||||
uploadCalls++;
|
||||
return uploadResult;
|
||||
}
|
||||
}
|
||||
|
||||
class _NoopMediaClient implements MediaServerClient {
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
||||
|
||||
@override
|
||||
ServerId get serverId => ServerId('server-1');
|
||||
|
||||
@override
|
||||
String? get serverName => 'Server';
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeFilePicker extends FilePicker {
|
||||
final Queue<Future<FilePickerResult?>> _results = Queue();
|
||||
|
||||
void queueResult(FilePickerResult? result) {
|
||||
_results.add(Future.value(result));
|
||||
}
|
||||
|
||||
void queueFuture(Future<FilePickerResult?> result) {
|
||||
_results.add(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FilePickerResult?> pickFiles({
|
||||
String? dialogTitle,
|
||||
String? initialDirectory,
|
||||
FileType type = FileType.any,
|
||||
List<String>? allowedExtensions,
|
||||
Function(FilePickerStatus)? onFileLoading,
|
||||
bool allowCompression = false,
|
||||
int compressionQuality = 0,
|
||||
bool allowMultiple = false,
|
||||
bool withData = false,
|
||||
bool withReadStream = false,
|
||||
bool lockParentWindow = false,
|
||||
bool readSequential = false,
|
||||
}) {
|
||||
return _results.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
http.Response _jsonResponse(Object body) {
|
||||
return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'});
|
||||
}
|
||||
|
||||
http.Response _ok() => _response(200);
|
||||
|
||||
http.Response _response(int statusCode) {
|
||||
return http.Response('{}', statusCode, headers: const {'content-type': 'application/json'});
|
||||
}
|
||||
@@ -456,6 +456,114 @@ void main() {
|
||||
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('pending D-pad move disables playlist mutations and restores the exact order on rejection', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = await _createHarness(_mediaItems(3));
|
||||
await _pushPlaylistRoute(tester, harness);
|
||||
|
||||
await _startFirstItemMoveDown(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
|
||||
expect(harness.client.moveRequests, hasLength(1));
|
||||
expect(harness.client.activeMutationCount, 1);
|
||||
expect(
|
||||
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)),
|
||||
everyElement(
|
||||
isA<PlaylistItemCard>()
|
||||
.having((card) => card.canReorder, 'canReorder', isFalse)
|
||||
.having((card) => card.onRemove, 'onRemove', isNull),
|
||||
),
|
||||
);
|
||||
expect(find.byType(ReorderableDragStartListener), findsNothing);
|
||||
|
||||
harness.client.completeMove(0, false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']);
|
||||
expect(harness.client.activeMutationCount, 0);
|
||||
expect(
|
||||
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)),
|
||||
everyElement(
|
||||
isA<PlaylistItemCard>()
|
||||
.having((card) => card.canReorder, 'canReorder', isTrue)
|
||||
.having((card) => card.onRemove, 'onRemove', isNotNull),
|
||||
),
|
||||
);
|
||||
|
||||
await _startFirstItemMoveDown(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
harness.client.completeMove(1, true, applyToServer: true);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
|
||||
expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']);
|
||||
expect(harness.client.peakMutationCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('ambiguous playlist move failure refetches authoritative order before reopening edits', (tester) async {
|
||||
final harness = await _createHarness(_mediaItems(3));
|
||||
await _pushPlaylistRoute(tester, harness);
|
||||
|
||||
await _startFirstItemMoveDown(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(harness.client.moveRequests, hasLength(1));
|
||||
|
||||
harness.client.failMove(0, applyToServer: true);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.client.requestedStarts, [0, 0]);
|
||||
expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']);
|
||||
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
|
||||
expect(harness.client.activeMutationCount, 0);
|
||||
expect(tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => card.canReorder), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('playlist remove rejection restores its snapshot and ambiguous failure refetches', (tester) async {
|
||||
final harness = await _createHarness(_mediaItems(3));
|
||||
await _pushPlaylistRoute(tester, harness);
|
||||
|
||||
await tester.tap(find.byTooltip(t.playlists.removeItem).first);
|
||||
await tester.pump();
|
||||
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']);
|
||||
expect(harness.client.removeRequests, hasLength(1));
|
||||
expect(
|
||||
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => card.onRemove == null),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
harness.client.completeRemove(0, false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']);
|
||||
|
||||
await tester.tap(find.byTooltip(t.playlists.removeItem).first);
|
||||
await tester.pump();
|
||||
harness.client.failRemove(1, applyToServer: true);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.client.requestedStarts, [0, 0]);
|
||||
expect(harness.client.authoritativeItemIds, ['item_1', 'item_2']);
|
||||
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']);
|
||||
expect(harness.client.activeMutationCount, 0);
|
||||
expect(harness.client.peakMutationCount, 1);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startFirstItemMoveDown(WidgetTester tester) async {
|
||||
final listFocus = find.byWidgetPredicate(
|
||||
(widget) => widget is Focus && widget.focusNode?.debugLabel == 'playlist_list',
|
||||
);
|
||||
tester.widget<Focus>(listFocus).focusNode!.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> _pushPlaylistRoute(WidgetTester tester, _PlaylistHarness harness) async {
|
||||
@@ -621,6 +729,10 @@ class _PagedPlaylistClient implements MediaServerClient {
|
||||
AbortController? activeAbort;
|
||||
final List<int?> requestedStarts = [];
|
||||
final List<int?> requestedSizes = [];
|
||||
final List<_MoveRequest> moveRequests = [];
|
||||
final List<_RemoveRequest> removeRequests = [];
|
||||
int activeMutationCount = 0;
|
||||
int peakMutationCount = 0;
|
||||
int deleteCalls = 0;
|
||||
bool _hasFailed = false;
|
||||
|
||||
@@ -633,6 +745,8 @@ class _PagedPlaylistClient implements MediaServerClient {
|
||||
|
||||
_PagedPlaylistClient._(this.items, this.failOnceAt, this.deleteResult, this._backend);
|
||||
|
||||
List<String> get authoritativeItemIds => items.map((item) => item.id).toList();
|
||||
|
||||
@override
|
||||
ServerId get serverId => ServerId('server_1');
|
||||
|
||||
@@ -668,6 +782,72 @@ class _PagedPlaylistClient implements MediaServerClient {
|
||||
return fakeLibraryPage(items, start: start, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> movePlaylistItem({
|
||||
required String playlistId,
|
||||
required MediaItem item,
|
||||
required int newIndex,
|
||||
required MediaItem? afterItem,
|
||||
}) async {
|
||||
final request = _MoveRequest(item: item, newIndex: newIndex);
|
||||
moveRequests.add(request);
|
||||
activeMutationCount++;
|
||||
peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount;
|
||||
try {
|
||||
return await request.result.future;
|
||||
} finally {
|
||||
activeMutationCount--;
|
||||
}
|
||||
}
|
||||
|
||||
void completeMove(int index, bool result, {bool applyToServer = false}) {
|
||||
final request = moveRequests[index];
|
||||
if (applyToServer) _applyMove(request);
|
||||
request.result.complete(result);
|
||||
}
|
||||
|
||||
void failMove(int index, {bool applyToServer = false}) {
|
||||
final request = moveRequests[index];
|
||||
if (applyToServer) _applyMove(request);
|
||||
request.result.completeError(StateError('connection closed after playlist move'), StackTrace.current);
|
||||
}
|
||||
|
||||
void _applyMove(_MoveRequest request) {
|
||||
final oldIndex = items.indexWhere((item) => item.id == request.item.id);
|
||||
if (oldIndex < 0) return;
|
||||
final item = items.removeAt(oldIndex);
|
||||
items.insert(request.newIndex.clamp(0, items.length), item);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) async {
|
||||
final request = _RemoveRequest(item);
|
||||
removeRequests.add(request);
|
||||
activeMutationCount++;
|
||||
peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount;
|
||||
try {
|
||||
return await request.result.future;
|
||||
} finally {
|
||||
activeMutationCount--;
|
||||
}
|
||||
}
|
||||
|
||||
void completeRemove(int index, bool result, {bool applyToServer = false}) {
|
||||
final request = removeRequests[index];
|
||||
if (applyToServer) _applyRemove(request);
|
||||
request.result.complete(result);
|
||||
}
|
||||
|
||||
void failRemove(int index, {bool applyToServer = false}) {
|
||||
final request = removeRequests[index];
|
||||
if (applyToServer) _applyRemove(request);
|
||||
request.result.completeError(StateError('connection closed after playlist removal'), StackTrace.current);
|
||||
}
|
||||
|
||||
void _applyRemove(_RemoveRequest request) {
|
||||
items.removeWhere((item) => item.id == request.item.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
|
||||
deleteCalls++;
|
||||
@@ -680,3 +860,18 @@ class _PagedPlaylistClient implements MediaServerClient {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _MoveRequest {
|
||||
final MediaItem item;
|
||||
final int newIndex;
|
||||
final Completer<bool> result = Completer<bool>();
|
||||
|
||||
_MoveRequest({required this.item, required this.newIndex});
|
||||
}
|
||||
|
||||
class _RemoveRequest {
|
||||
final MediaItem item;
|
||||
final Completer<bool> result = Completer<bool>();
|
||||
|
||||
_RemoveRequest(this.item);
|
||||
}
|
||||
|
||||
@@ -9,149 +9,81 @@ import 'package:plezy/services/companion_remote/remote_auth_service.dart';
|
||||
|
||||
void main() {
|
||||
group('LanDiscoveryService', () {
|
||||
test(
|
||||
'publishes a changed normalized IP set for an existing host',
|
||||
() async {
|
||||
final context = _authContext(
|
||||
id: 'context-a',
|
||||
discoveryKey: List<int>.generate(32, (index) => index),
|
||||
);
|
||||
final listener = await _DiscoveryListener.start([context]);
|
||||
test('publishes a changed normalized IP set for an existing host', () async {
|
||||
final context = _authContext(id: 'context-a', discoveryKey: List<int>.generate(32, (index) => index));
|
||||
final listener = await _DiscoveryListener.start([context]);
|
||||
|
||||
try {
|
||||
listener.sendBeacon(context: context, ips: const ['192.0.2.10']);
|
||||
await _waitFor(() => listener.emissions.length == 1);
|
||||
try {
|
||||
await listener.sendBeacon(context: context, ips: const ['192.0.2.10']);
|
||||
await _waitFor(() => listener.emissions.length == 1);
|
||||
|
||||
listener.sendBeacon(
|
||||
context: context,
|
||||
ips: const ['192.0.2.30', '10.0.0.30'],
|
||||
);
|
||||
await _waitFor(() => listener.emissions.length == 2);
|
||||
await listener.sendBeacon(context: context, ips: const ['192.0.2.30', '10.0.0.30']);
|
||||
await _waitFor(() => listener.emissions.length == 2);
|
||||
|
||||
final hosts = listener.emissions.last;
|
||||
expect(hosts, hasLength(1));
|
||||
final host = hosts.single;
|
||||
expect(host.clientId, 'shared-client');
|
||||
expect(host.authContextId, 'context-a');
|
||||
expect(host.ips, ['10.0.0.30', '192.0.2.30']);
|
||||
expect(
|
||||
host.addresses,
|
||||
unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100']),
|
||||
);
|
||||
expect(host.addresses, isNot(contains('192.0.2.10:52100')));
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
final hosts = listener.emissions.last;
|
||||
expect(hosts, hasLength(1));
|
||||
final host = hosts.single;
|
||||
expect(host.clientId, 'shared-client');
|
||||
expect(host.authContextId, 'context-a');
|
||||
expect(host.ips, ['10.0.0.30', '192.0.2.30']);
|
||||
expect(host.addresses, unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100']));
|
||||
expect(host.addresses, isNot(contains('192.0.2.10:52100')));
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'suppresses reordered IPs and publishes a platform-only change',
|
||||
() async {
|
||||
final context = _authContext(
|
||||
id: 'context-a',
|
||||
discoveryKey: List<int>.generate(32, (index) => index + 32),
|
||||
);
|
||||
final listener = await _DiscoveryListener.start([context]);
|
||||
test('suppresses reordered IPs and publishes a platform-only change', () async {
|
||||
final context = _authContext(id: 'context-a', discoveryKey: List<int>.generate(32, (index) => index + 32));
|
||||
final listener = await _DiscoveryListener.start([context]);
|
||||
|
||||
try {
|
||||
listener.sendBeacon(
|
||||
context: context,
|
||||
platform: 'macOS',
|
||||
ips: const ['192.0.2.40', '10.0.0.40'],
|
||||
);
|
||||
await _waitFor(() => listener.emissions.length == 1);
|
||||
try {
|
||||
await listener.sendBeacon(context: context, platform: 'macOS', ips: const ['192.0.2.40', '10.0.0.40']);
|
||||
await _waitFor(() => listener.emissions.length == 1);
|
||||
|
||||
listener.sendBeacon(
|
||||
context: context,
|
||||
platform: 'macOS',
|
||||
ips: const ['10.0.0.40', '192.0.2.40'],
|
||||
);
|
||||
listener.sendBeacon(
|
||||
context: context,
|
||||
platform: 'Android',
|
||||
ips: const ['192.0.2.40', '10.0.0.40'],
|
||||
);
|
||||
await _waitFor(
|
||||
() => listener.emissions.any(
|
||||
(hosts) => hosts.single.platform == 'Android',
|
||||
),
|
||||
);
|
||||
await listener.sendBeacon(context: context, platform: 'macOS', ips: const ['10.0.0.40', '192.0.2.40']);
|
||||
await listener.sendBeacon(context: context, platform: 'Android', ips: const ['192.0.2.40', '10.0.0.40']);
|
||||
await _waitFor(() => listener.emissions.any((hosts) => hosts.single.platform == 'Android'));
|
||||
|
||||
expect(listener.emissions, hasLength(2));
|
||||
final hosts = listener.emissions.last;
|
||||
expect(hosts, hasLength(1));
|
||||
final host = hosts.single;
|
||||
expect(host.clientId, 'shared-client');
|
||||
expect(host.platform, 'Android');
|
||||
expect(
|
||||
host.addresses,
|
||||
unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100']),
|
||||
);
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(listener.emissions, hasLength(2));
|
||||
final hosts = listener.emissions.last;
|
||||
expect(hosts, hasLength(1));
|
||||
final host = hosts.single;
|
||||
expect(host.clientId, 'shared-client');
|
||||
expect(host.platform, 'Android');
|
||||
expect(host.addresses, unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100']));
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'suppresses context-only churn and retains the usable context',
|
||||
() async {
|
||||
final firstContext = _authContext(
|
||||
id: 'context-a',
|
||||
discoveryKey: List<int>.generate(32, (index) => index + 64),
|
||||
);
|
||||
final secondContext = _authContext(
|
||||
id: 'context-b',
|
||||
discoveryKey: List<int>.generate(32, (index) => index + 96),
|
||||
);
|
||||
final listener = await _DiscoveryListener.start([
|
||||
firstContext,
|
||||
secondContext,
|
||||
]);
|
||||
test('suppresses context-only churn and retains the usable context', () async {
|
||||
final firstContext = _authContext(id: 'context-a', discoveryKey: List<int>.generate(32, (index) => index + 64));
|
||||
final secondContext = _authContext(id: 'context-b', discoveryKey: List<int>.generate(32, (index) => index + 96));
|
||||
final listener = await _DiscoveryListener.start([firstContext, secondContext]);
|
||||
|
||||
try {
|
||||
listener.sendBeacon(
|
||||
context: firstContext,
|
||||
name: 'Living Room',
|
||||
ips: const ['192.0.2.50'],
|
||||
);
|
||||
await _waitFor(() => listener.emissions.length == 1);
|
||||
try {
|
||||
await listener.sendBeacon(context: firstContext, name: 'Living Room', ips: const ['192.0.2.50']);
|
||||
await _waitFor(() => listener.emissions.length == 1);
|
||||
|
||||
listener.sendBeacon(
|
||||
context: secondContext,
|
||||
name: 'Living Room',
|
||||
ips: const ['192.0.2.50'],
|
||||
);
|
||||
listener.sendBeacon(
|
||||
context: secondContext,
|
||||
name: 'Living Room TV',
|
||||
ips: const ['192.0.2.50'],
|
||||
);
|
||||
await _waitFor(
|
||||
() => listener.emissions.any(
|
||||
(hosts) => hosts.single.name == 'Living Room TV',
|
||||
),
|
||||
);
|
||||
await listener.sendBeacon(context: secondContext, name: 'Living Room', ips: const ['192.0.2.50']);
|
||||
await listener.sendBeacon(context: secondContext, name: 'Living Room TV', ips: const ['192.0.2.50']);
|
||||
await _waitFor(() => listener.emissions.any((hosts) => hosts.single.name == 'Living Room TV'));
|
||||
|
||||
expect(listener.emissions, hasLength(2));
|
||||
final hosts = listener.emissions.last;
|
||||
expect(hosts, hasLength(1));
|
||||
expect(hosts.single.clientId, 'shared-client');
|
||||
expect(hosts.single.name, 'Living Room TV');
|
||||
expect(hosts.single.authContextId, 'context-a');
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(listener.emissions, hasLength(2));
|
||||
final hosts = listener.emissions.last;
|
||||
expect(hosts, hasLength(1));
|
||||
expect(hosts.single.clientId, 'shared-client');
|
||||
expect(hosts.single.name, 'Living Room TV');
|
||||
expect(hosts.single.authContextId, 'context-a');
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
RemoteAuthContext _authContext({
|
||||
required String id,
|
||||
required List<int> discoveryKey,
|
||||
}) {
|
||||
RemoteAuthContext _authContext({required String id, required List<int> discoveryKey}) {
|
||||
return RemoteAuthContext(
|
||||
id: id,
|
||||
backend: 'plex',
|
||||
@@ -177,27 +109,18 @@ class _DiscoveryListener {
|
||||
final StreamSubscription<List<DiscoveredHost>> subscription;
|
||||
final List<List<DiscoveredHost>> emissions;
|
||||
|
||||
static Future<_DiscoveryListener> start(
|
||||
List<RemoteAuthContext> contexts,
|
||||
) async {
|
||||
final service = LanDiscoveryService();
|
||||
static Future<_DiscoveryListener> start(List<RemoteAuthContext> contexts) async {
|
||||
final reservation = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final discoveryPort = reservation.port;
|
||||
reservation.close();
|
||||
final service = LanDiscoveryService(discoveryPort: discoveryPort);
|
||||
final emissions = <List<DiscoveredHost>>[];
|
||||
final subscription = service
|
||||
.startListeningForContexts(contexts)
|
||||
.listen(emissions.add);
|
||||
final sender = await RawDatagramSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
final subscription = service.startListeningForContexts(contexts).listen(emissions.add);
|
||||
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
|
||||
try {
|
||||
await _waitFor(() => service.isListening);
|
||||
return _DiscoveryListener._(
|
||||
service: service,
|
||||
sender: sender,
|
||||
subscription: subscription,
|
||||
emissions: emissions,
|
||||
);
|
||||
return _DiscoveryListener._(service: service, sender: sender, subscription: subscription, emissions: emissions);
|
||||
} catch (_) {
|
||||
sender.close();
|
||||
await subscription.cancel();
|
||||
@@ -206,13 +129,13 @@ class _DiscoveryListener {
|
||||
}
|
||||
}
|
||||
|
||||
void sendBeacon({
|
||||
Future<void> sendBeacon({
|
||||
required RemoteAuthContext context,
|
||||
required List<String> ips,
|
||||
String name = 'Living Room',
|
||||
String platform = 'macOS',
|
||||
int port = 52100,
|
||||
}) {
|
||||
}) async {
|
||||
const version = 1;
|
||||
final auth = RemoteAuthService.instance;
|
||||
final homeHash = auth.computeDiscoveryTag(context.discoveryKey);
|
||||
@@ -240,11 +163,11 @@ class _DiscoveryListener {
|
||||
}),
|
||||
);
|
||||
|
||||
sender.send(
|
||||
packet,
|
||||
InternetAddress.loopbackIPv4,
|
||||
LanDiscoveryService.discoveryPort,
|
||||
);
|
||||
for (var attempt = 0; attempt < 100; attempt++) {
|
||||
if (sender.send(packet, InternetAddress.loopbackIPv4, service.discoveryPort) == packet.length) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('Timed out sending LAN discovery beacon');
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
|
||||
@@ -670,6 +670,8 @@ class _RawWebSocketClient {
|
||||
}
|
||||
|
||||
static Future<_RawWebSocketClient> connect(int port) async {
|
||||
// Ownership transfers to the returned helper.
|
||||
// ignore: close_sinks
|
||||
final socket = await WebSocket.connect('ws://127.0.0.1:$port/ws').timeout(_ioTimeout);
|
||||
final client = _RawWebSocketClient._(socket);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/metadata_edit/plex_metadata_edit_adapter.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase database;
|
||||
|
||||
setUp(() {
|
||||
database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(database);
|
||||
});
|
||||
|
||||
tearDown(() => database.close());
|
||||
|
||||
test('metadata preference request uses includePreferences and canonicalizes server values', () async {
|
||||
Uri? requestedUri;
|
||||
final client = testPlexClient(
|
||||
handler: (request) async {
|
||||
requestedUri = request.url;
|
||||
return _metadataResponse([
|
||||
{'id': 'episodeSort', 'value': 0},
|
||||
{'id': 'autoDeletionItemPolicyUnwatchedLibrary', 'value': 3},
|
||||
{'id': 'autoDeletionItemPolicyWatchedLibrary', 'value': 7},
|
||||
{'id': 'flattenSeasons', 'value': 1},
|
||||
{'id': 'showOrdering', 'value': 'tvdbAbsolute'},
|
||||
{'id': 'languageOverride', 'value': 'fr-FR'},
|
||||
{'id': 'useOriginalTitle', 'value': true},
|
||||
{'id': 'audioLanguage', 'value': 'ja'},
|
||||
{'id': 'subtitleLanguage', 'value': 'en'},
|
||||
{'id': 'subtitleMode', 'value': 2},
|
||||
]);
|
||||
},
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final draft = await PlexMetadataEditAdapter(client).load(_show());
|
||||
|
||||
expect(requestedUri?.path, '/library/metadata/show-1');
|
||||
expect(requestedUri?.queryParameters['includePreferences'], '1');
|
||||
expect(draft.value<String>('originalTitle'), 'Original show title');
|
||||
expect(draft.value<String>('pref:episodeSort'), '0');
|
||||
expect(draft.value<String>('pref:autoDeletionItemPolicyUnwatchedLibrary'), '3');
|
||||
expect(draft.value<String>('pref:autoDeletionItemPolicyWatchedLibrary'), '7');
|
||||
expect(draft.value<String>('pref:flattenSeasons'), '1');
|
||||
expect(draft.value<String>('pref:showOrdering'), 'tvdbAbsolute');
|
||||
expect(draft.value<String>('pref:languageOverride'), 'fr-FR');
|
||||
expect(draft.value<String>('pref:useOriginalTitle'), '1');
|
||||
expect(draft.value<String>('pref:audioLanguage'), 'ja');
|
||||
expect(draft.value<String>('pref:subtitleLanguage'), 'en');
|
||||
expect(draft.value<String>('pref:subtitleMode'), '2');
|
||||
});
|
||||
|
||||
test('missing preference rows remain unset instead of becoming fabricated defaults', () async {
|
||||
final client = testPlexClient(
|
||||
handler: (_) async => _metadataResponse([
|
||||
{'id': 'episodeSort', 'value': 0},
|
||||
]),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final draft = await PlexMetadataEditAdapter(client).load(_show());
|
||||
|
||||
expect(draft.value<String>('pref:episodeSort'), '0');
|
||||
expect(draft.value<String>('pref:flattenSeasons'), isNull);
|
||||
expect(draft.value<String>('pref:showOrdering'), isNull);
|
||||
expect(draft.value<String>('pref:languageOverride'), isNull);
|
||||
expect(draft.value<String>('pref:useOriginalTitle'), isNull);
|
||||
expect(draft.value<String>('pref:audioLanguage'), isNull);
|
||||
expect(draft.value<String>('pref:subtitleMode'), isNull);
|
||||
});
|
||||
|
||||
test('metadata preference request failures preserve the editable basic fields', () async {
|
||||
final client = testPlexClient(handler: (_) async => http.Response('{}', 503));
|
||||
addTearDown(client.close);
|
||||
|
||||
final draft = await PlexMetadataEditAdapter(client).load(_show());
|
||||
|
||||
expect(draft.value<String>('title'), 'Show');
|
||||
expect(draft.value<String>('originalTitle'), 'Original show title');
|
||||
expect(draft.value<String>('pref:episodeSort'), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
http.Response _metadataResponse(List<Object?> settings) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{'ratingKey': 'show-1', 'Setting': settings},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
MediaItem _show() => testMediaItem(
|
||||
id: 'show-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
title: 'Show',
|
||||
originalTitle: 'Original show title',
|
||||
summary: 'Summary',
|
||||
libraryId: '1',
|
||||
);
|
||||
Reference in New Issue
Block a user