fix(media): serialize browsing and metadata mutations

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent 43a8fe020d
commit f8bfecf57d
30 changed files with 3088 additions and 465 deletions
@@ -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);
}
+247 -1
View File
@@ -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);
}
+651
View File
@@ -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',
);