Files
plezy/test/screens/metadata_edit_screen_test.dart
T
edde746 316a69a1de refactor: share the paginated grid tab, cached remote store, and tile focus
- PaginatedCardGridTabState: the collections and playlists tabs were 95%
  identical; they now supply only pageSize/fetchPage/idOf instead of each
  duplicating the grid, memo, inflation budget and focus wiring.
- EtagCachedRemoteStore: the anime-lists and fribb mapping stores now share
  one download/cache/isolate-parse/conditional-GET lifecycle.
- FocusableTileStateMixin manages its own initState/didUpdateWidget/dispose
  instead of requiring every caller to forward three lifecycle hooks.

Also drops unused ServerCapabilities entries and dead code in
focusable_list_tile and music/track_row.
2026-07-26 06:09:48 +02:00

652 lines
22 KiB
Dart

import 'dart:async';
import 'dart:collection';
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/file_picker_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/http_fixtures.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.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])),
]),
);
FilePickerService.setDelegateForTesting(picker);
addTearDown(() => FilePickerService.setDelegateForTesting(null));
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);
FilePickerService.setDelegateForTesting(picker);
addTearDown(() => FilePickerService.setDelegateForTesting(null));
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 = testMultiServerProvider(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 implements FilePickerDelegate {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
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 _ok() => _response(200);
http.Response _response(int statusCode) {
return http.Response('{}', statusCode, headers: const {'content-type': 'application/json'});
}