300 lines
10 KiB
Dart
300 lines
10 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/media/ids.dart';
|
|
import 'package:plezy/media/media_backend.dart';
|
|
import 'package:plezy/media/media_hub.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/server_capabilities.dart';
|
|
import 'package:plezy/providers/discover_provider.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/services/data_aggregation_service.dart';
|
|
import 'package:plezy/services/multi_server_manager.dart';
|
|
import 'package:plezy/services/settings_service.dart';
|
|
import 'package:plezy/utils/watch_state_notifier.dart';
|
|
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
MediaItem _item(String id, {String? parentId}) => MediaItem(
|
|
id: id,
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: id,
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
parentId: parentId,
|
|
);
|
|
|
|
MediaHub _hub(String id, {String? identifier, String? libraryId, List<MediaItem>? items}) => MediaHub(
|
|
id: id,
|
|
title: id,
|
|
type: 'movie',
|
|
identifier: identifier,
|
|
items: items ?? [_item('$id-item')],
|
|
size: 1,
|
|
libraryId: libraryId,
|
|
serverId: 'server_1',
|
|
);
|
|
|
|
/// Counting fake — the provider's fetch-cost policy is the contract under
|
|
/// test: a watch event must cost exactly one on-deck call and zero hub
|
|
/// refetches, an order change zero calls, a hidden-set change one full pass.
|
|
class _FakeAggregationService extends DataAggregationService {
|
|
_FakeAggregationService(super.serverManager);
|
|
|
|
int onDeckCalls = 0;
|
|
int hubCalls = 0;
|
|
List<MediaItem> Function() onDeckResult = () => const [];
|
|
List<MediaHub> Function() hubsResult = () => const [];
|
|
|
|
@override
|
|
Future<List<MediaItem>> getOnDeckFromAllServers({int? limit, Set<String>? hiddenLibraryKeys}) async {
|
|
onDeckCalls++;
|
|
final items = onDeckResult();
|
|
return limit != null && items.length > limit ? items.sublist(0, limit) : items;
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaHub>> getHubsFromAllServers({
|
|
int? limit,
|
|
Set<String>? hiddenLibraryKeys,
|
|
bool useGlobalHubs = true,
|
|
bool includePlaybackHubs = true,
|
|
}) async {
|
|
hubCalls++;
|
|
return hubsResult();
|
|
}
|
|
}
|
|
|
|
class _FakeClient implements MediaServerClient {
|
|
MediaItem? itemResult;
|
|
|
|
@override
|
|
ServerId get serverId => ServerId('server_1');
|
|
|
|
@override
|
|
String? get serverName => 'Server';
|
|
|
|
@override
|
|
MediaBackend get backend => MediaBackend.plex;
|
|
|
|
@override
|
|
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
|
|
|
@override
|
|
Future<MediaItem?> fetchItem(String id, {bool useCache = true}) async => itemResult;
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late _FakeClient client;
|
|
late _FakeAggregationService aggregation;
|
|
late MultiServerProvider multiServer;
|
|
late HiddenLibrariesProvider hiddenLibraries;
|
|
late LibrariesProvider libraries;
|
|
late DiscoverProvider provider;
|
|
bool isBinding = false;
|
|
|
|
setUp(() async {
|
|
resetSharedPreferencesForTest();
|
|
SettingsService.resetForTesting();
|
|
await SettingsService.getInstance();
|
|
isBinding = false;
|
|
|
|
client = _FakeClient();
|
|
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
|
aggregation = _FakeAggregationService(manager);
|
|
multiServer = MultiServerProvider(manager, aggregation);
|
|
hiddenLibraries = HiddenLibrariesProvider();
|
|
libraries = LibrariesProvider();
|
|
provider = DiscoverProvider(multiServer, hiddenLibraries, libraries, isProfileBinding: () => isBinding);
|
|
});
|
|
|
|
tearDown(() {
|
|
provider.dispose();
|
|
libraries.dispose();
|
|
hiddenLibraries.dispose();
|
|
multiServer.dispose();
|
|
});
|
|
|
|
test('load publishes on-deck and hubs; concurrent calls coalesce', () async {
|
|
aggregation.onDeckResult = () => [_item('a')];
|
|
aggregation.hubsResult = () => [_hub('hub-1')];
|
|
|
|
// Three synchronous calls: one in-flight pass plus at most one trailing
|
|
// pass (a request arriving mid-load must observe its own fresh fetch).
|
|
await Future.wait([provider.load(), provider.load(), provider.load()]);
|
|
|
|
expect(provider.onDeck.map((i) => i.id), ['a']);
|
|
expect(provider.hubs.map((h) => h.id), ['hub-1']);
|
|
expect(provider.isLoading, isFalse);
|
|
expect(provider.areHubsLoading, isFalse);
|
|
expect(provider.errorMessage, isNull);
|
|
expect(aggregation.onDeckCalls, 2);
|
|
expect(aggregation.hubCalls, 2);
|
|
});
|
|
|
|
test('limits the preview row and probes for more', () async {
|
|
aggregation.onDeckResult = () => [for (var i = 0; i < 30; i++) _item('item-$i')];
|
|
|
|
await provider.load();
|
|
|
|
expect(provider.onDeck, hasLength(DiscoverProvider.continueWatchingPreviewLimit));
|
|
expect(provider.hasMoreContinueWatching, isTrue);
|
|
});
|
|
|
|
test('filters playback-progress hubs that duplicate the continue watching row', () async {
|
|
aggregation.hubsResult = () => [
|
|
_hub('keep'),
|
|
_hub('cw', identifier: 'home.continue'),
|
|
_hub('od', identifier: 'home.ondeck'),
|
|
_hub('nu', identifier: 'home.nextup'),
|
|
];
|
|
|
|
await provider.load();
|
|
|
|
expect(provider.hubs.map((h) => h.id), ['keep']);
|
|
});
|
|
|
|
test('watch event refreshes continue watching with one call and zero hub refetches', () async {
|
|
aggregation.onDeckResult = () => [_item('ep-1', parentId: 'season-1')];
|
|
aggregation.hubsResult = () => [_hub('hub-1')];
|
|
await provider.load();
|
|
final onDeckCallsBefore = aggregation.onDeckCalls;
|
|
final hubCallsBefore = aggregation.hubCalls;
|
|
|
|
WatchStateNotifier().notifyWatched(item: _item('ep-1', parentId: 'season-1'));
|
|
await pumpEventQueue();
|
|
|
|
expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
|
|
expect(aggregation.hubCalls, hubCallsBefore);
|
|
});
|
|
|
|
test('removal event drops the row immediately, then refreshes in background', () async {
|
|
aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')];
|
|
await provider.load();
|
|
|
|
var sawImmediateRemoval = false;
|
|
provider.addListener(() {
|
|
if (provider.onDeck.length == 1 && provider.onDeck.single.id == 'ep-2') {
|
|
sawImmediateRemoval = true;
|
|
}
|
|
});
|
|
aggregation.onDeckResult = () => [_item('ep-2')];
|
|
|
|
WatchStateNotifier().notifyRemovedFromContinueWatching(item: _item('ep-1'));
|
|
await pumpEventQueue();
|
|
|
|
expect(sawImmediateRemoval, isTrue);
|
|
expect(provider.onDeck.map((i) => i.id), ['ep-2']);
|
|
});
|
|
|
|
test('library order change re-sorts hubs without any refetch', () async {
|
|
aggregation.hubsResult = () => [
|
|
_hub('hub-lib2', libraryId: 'lib-2'),
|
|
_hub('hub-lib1', libraryId: 'lib-1'),
|
|
];
|
|
await provider.load();
|
|
expect(provider.hubs.map((h) => h.id), ['hub-lib2', 'hub-lib1']);
|
|
final hubCallsBefore = aggregation.hubCalls;
|
|
|
|
MediaLibrary lib(String id) =>
|
|
MediaLibrary(id: id, backend: MediaBackend.plex, title: id, serverId: 'server_1');
|
|
await libraries.updateLibraryOrder([lib('lib-1'), lib('lib-2')]);
|
|
await pumpEventQueue();
|
|
|
|
expect(provider.hubs.map((h) => h.id), ['hub-lib1', 'hub-lib2']);
|
|
expect(aggregation.hubCalls, hubCallsBefore);
|
|
});
|
|
|
|
test('hidden-library change triggers exactly one full reload', () async {
|
|
await provider.load();
|
|
final onDeckCallsBefore = aggregation.onDeckCalls;
|
|
final hubCallsBefore = aggregation.hubCalls;
|
|
|
|
await hiddenLibraries.hideLibrary('server_1:lib-1');
|
|
await pumpEventQueue();
|
|
|
|
expect(aggregation.onDeckCalls, onDeckCallsBefore + 1);
|
|
expect(aggregation.hubCalls, hubCallsBefore + 1);
|
|
});
|
|
|
|
test('refreshContinueWatching never flips states or surfaces errors', () async {
|
|
aggregation.onDeckResult = () => [_item('a')];
|
|
await provider.load();
|
|
|
|
aggregation.onDeckResult = () => throw Exception('server down');
|
|
await provider.refreshContinueWatching();
|
|
|
|
expect(provider.onDeck.map((i) => i.id), ['a']);
|
|
expect(provider.errorMessage, isNull);
|
|
expect(provider.isLoading, isFalse);
|
|
});
|
|
|
|
test('load failure surfaces the error and ends both loading states', () async {
|
|
aggregation.onDeckResult = () => throw Exception('boom');
|
|
|
|
await provider.load();
|
|
|
|
expect(provider.errorMessage, contains('boom'));
|
|
expect(provider.isLoading, isFalse);
|
|
expect(provider.areHubsLoading, isFalse);
|
|
});
|
|
|
|
test('no servers while the profile binder runs stays loading instead of erroring', () async {
|
|
final emptyManager = MultiServerManager();
|
|
final emptyAggregation = _FakeAggregationService(emptyManager);
|
|
final emptyMultiServer = MultiServerProvider(emptyManager, emptyAggregation);
|
|
addTearDown(emptyMultiServer.dispose);
|
|
final binderProvider = DiscoverProvider(
|
|
emptyMultiServer,
|
|
hiddenLibraries,
|
|
libraries,
|
|
isProfileBinding: () => isBinding,
|
|
);
|
|
addTearDown(binderProvider.dispose);
|
|
|
|
isBinding = true;
|
|
await binderProvider.load();
|
|
expect(binderProvider.isLoading, isTrue);
|
|
expect(binderProvider.errorMessage, isNull);
|
|
|
|
isBinding = false;
|
|
await binderProvider.load();
|
|
expect(binderProvider.isLoading, isFalse);
|
|
expect(binderProvider.errorMessage, isNotNull);
|
|
});
|
|
|
|
test('updateItem refetches one item and swaps it in place', () async {
|
|
aggregation.onDeckResult = () => [_item('ep-1')];
|
|
aggregation.hubsResult = () => [
|
|
_hub('hub-1', items: [_item('movie-1')]),
|
|
];
|
|
await provider.load();
|
|
|
|
client.itemResult = _item('movie-1').copyWith(title: 'Updated Title');
|
|
await provider.updateItem('movie-1');
|
|
|
|
expect(provider.hubs.single.items.single.title, 'Updated Title');
|
|
expect(provider.onDeck.single.id, 'ep-1');
|
|
});
|
|
|
|
test('loadGeneration bumps on full loads only', () async {
|
|
aggregation.onDeckResult = () => [_item('a')];
|
|
final initial = provider.loadGeneration;
|
|
|
|
await provider.load();
|
|
expect(provider.loadGeneration, initial + 1);
|
|
|
|
await provider.refreshContinueWatching();
|
|
expect(provider.loadGeneration, initial + 1);
|
|
});
|
|
}
|