- 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.
604 lines
23 KiB
Dart
604 lines
23 KiB
Dart
import 'dart:async';
|
|
import 'package:plezy/media/ids.dart';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/exceptions/media_server_exceptions.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/media/media_server_client.dart';
|
|
import 'package:plezy/providers/libraries_provider.dart';
|
|
import 'package:plezy/services/data_aggregation_service.dart';
|
|
import 'package:plezy/services/multi_server_manager.dart';
|
|
import 'package:plezy/services/storage_service.dart';
|
|
|
|
import '../test_helpers/multi_server_fixtures.dart';
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
MediaLibrary _lib(String key, {String type = 'movie', ServerId? serverId, String title = 'L'}) => MediaLibrary(
|
|
id: key,
|
|
backend: MediaBackend.plex,
|
|
title: title,
|
|
kind: MediaKind.fromString(type),
|
|
serverId: serverId,
|
|
);
|
|
|
|
MediaLibrary _serverLib(ServerId serverId, String id, String title) =>
|
|
MediaLibrary(id: id, backend: MediaBackend.plex, title: title, kind: MediaKind.movie, serverId: serverId);
|
|
|
|
/// Minimal [MediaServerClient] returning canned libraries; only the surface the
|
|
/// aggregation service touches is implemented. An optional [gate] lets a test
|
|
/// hold `fetchLibraries` open to exercise the mid-load race; setting [error]
|
|
/// makes `fetchLibraries` throw, simulating a (possibly transient) failure.
|
|
class _FakeClient implements MediaServerClient {
|
|
_FakeClient({required this.serverId, this.libraries = const [], this.gate, this.errorForCall});
|
|
|
|
@override
|
|
final ServerId serverId;
|
|
@override
|
|
final String serverName = 'Server';
|
|
|
|
final List<MediaLibrary> libraries;
|
|
final Future<void>? gate;
|
|
final Object? Function(int call)? errorForCall;
|
|
|
|
/// When non-null, [fetchLibraries] throws this instead of returning. Mutable
|
|
/// so a test can fail a fetch once and then let it recover.
|
|
Object? error;
|
|
|
|
int fetchLibrariesCalls = 0;
|
|
|
|
@override
|
|
Future<List<MediaLibrary>> fetchLibraries() async {
|
|
fetchLibrariesCalls++;
|
|
final pending = gate;
|
|
if (pending != null) await pending;
|
|
final fetchError = errorForCall?.call(fetchLibrariesCalls) ?? error;
|
|
if (fetchError != null) throw fetchError;
|
|
return libraries;
|
|
}
|
|
|
|
@override
|
|
void close() {}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
void main() {
|
|
setUp(resetSharedPreferencesForTest);
|
|
|
|
group('LibrariesProvider', () {
|
|
test('starts with initial empty state', () {
|
|
final p = LibrariesProvider();
|
|
expect(p.libraries, isEmpty);
|
|
expect(p.hasLibraries, isFalse);
|
|
expect(p.isLoading, isFalse);
|
|
expect(p.hasLoaded, isFalse);
|
|
expect(p.loadState, LibrariesLoadState.initial);
|
|
expect(p.errorMessage, isNull);
|
|
p.dispose();
|
|
});
|
|
|
|
test('loadLibraries before initialize is a no-op', () async {
|
|
final p = LibrariesProvider();
|
|
var notified = 0;
|
|
p.addListener(() => notified++);
|
|
|
|
await p.loadLibraries();
|
|
|
|
// Without a DataAggregationService the load short-circuits with no
|
|
// state transition and no listener notification.
|
|
expect(p.loadState, LibrariesLoadState.initial);
|
|
expect(p.libraries, isEmpty);
|
|
expect(notified, 0);
|
|
|
|
p.dispose();
|
|
});
|
|
|
|
test('refresh before initialize is a no-op', () async {
|
|
final p = LibrariesProvider();
|
|
var notified = 0;
|
|
p.addListener(() => notified++);
|
|
|
|
await p.refresh();
|
|
expect(p.loadState, LibrariesLoadState.initial);
|
|
expect(notified, 0);
|
|
|
|
p.dispose();
|
|
});
|
|
|
|
test('updateLibraryOrder updates list, notifies, and persists order', () async {
|
|
final p = LibrariesProvider();
|
|
var notified = 0;
|
|
p.addListener(() => notified++);
|
|
|
|
final libs = [
|
|
_lib('1', serverId: ServerId('srv'), title: 'A'),
|
|
_lib('2', serverId: ServerId('srv'), title: 'B'),
|
|
_lib('3', serverId: ServerId('srv'), title: 'C'),
|
|
];
|
|
|
|
await p.updateLibraryOrder(libs);
|
|
expect(p.libraries.length, 3);
|
|
expect(p.libraries.map((l) => l.title), ['A', 'B', 'C']);
|
|
expect(notified, 1);
|
|
|
|
// Persisted to storage as the list of globalKeys.
|
|
final storage = await StorageService.getInstance();
|
|
expect(storage.getLibraryOrder(), equals(libs.map((l) => l.globalKey).toList()));
|
|
|
|
p.dispose();
|
|
});
|
|
|
|
test('libraries getter returns an unmodifiable list', () async {
|
|
final p = LibrariesProvider();
|
|
await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]);
|
|
expect(() => p.libraries.add(_lib('mutated')), throwsUnsupportedError);
|
|
p.dispose();
|
|
});
|
|
|
|
test('clear resets state to initial and notifies', () async {
|
|
final p = LibrariesProvider();
|
|
await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv')), _lib('2', serverId: ServerId('srv'))]);
|
|
expect(p.libraries, hasLength(2));
|
|
|
|
var notified = 0;
|
|
p.addListener(() => notified++);
|
|
|
|
p.clear();
|
|
expect(p.libraries, isEmpty);
|
|
expect(p.hasLibraries, isFalse);
|
|
expect(p.loadState, LibrariesLoadState.initial);
|
|
expect(p.errorMessage, isNull);
|
|
expect(notified, 1);
|
|
|
|
p.dispose();
|
|
});
|
|
|
|
test('mutating methods after dispose are no-ops', () async {
|
|
final p = LibrariesProvider();
|
|
p.dispose();
|
|
p.clear();
|
|
await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]);
|
|
|
|
expect(p.libraries, isEmpty);
|
|
final storage = await StorageService.getInstance();
|
|
expect(storage.getLibraryOrder(), isNull);
|
|
});
|
|
});
|
|
|
|
group('LibrariesProvider.syncToOnlineServers', () {
|
|
test('loads when a server first comes online', () async {
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'A'});
|
|
|
|
expect(p.hasLoaded, isTrue);
|
|
expect(p.libraries.map((l) => l.title), ['Movies A']);
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('does not reload when the online set is unchanged', () async {
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'A'});
|
|
await p.syncToOnlineServers({'A'}); // already covered → no-op
|
|
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('delta-loads only a server that connects after the first load', () async {
|
|
// A server binding in a later wave (borrowed connection, or a slow
|
|
// server reconnecting after timing out) must surface without a profile
|
|
// re-switch — and without refetching the servers already loaded.
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'A'});
|
|
expect(p.libraries.map((l) => l.title), ['Movies A']);
|
|
|
|
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
|
|
manager.debugRegisterClientForTesting(clientB);
|
|
await p.syncToOnlineServers({'A', 'B'});
|
|
|
|
expect(p.libraries.map((l) => l.title), containsAll(<String>['Movies A', 'Shows B']));
|
|
expect(clientA.fetchLibrariesCalls, 1, reason: 'already-loaded server is not refetched');
|
|
expect(clientB.fetchLibrariesCalls, 1);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a background reload over existing data never surfaces a loading state', () async {
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'A'});
|
|
expect(p.hasLoaded, isTrue);
|
|
|
|
// A server connecting later must not flip the provider back to a loading
|
|
// state — screens render `isLoading` as a full-screen spinner, so a
|
|
// background reload would blank content the user is already viewing.
|
|
final sawLoading = <bool>[];
|
|
p.addListener(() => sawLoading.add(p.isLoading));
|
|
|
|
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
|
|
manager.debugRegisterClientForTesting(clientB);
|
|
await p.syncToOnlineServers({'A', 'B'});
|
|
|
|
expect(sawLoading, isNot(contains(true)));
|
|
expect(p.libraries.map((l) => l.title), containsAll(<String>['Movies A', 'Shows B']));
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a server whose fetch fails is retried on the next sync, not cached as loaded', () async {
|
|
// Regression: getMediaLibrariesFromAllServers swallows a per-server fetch
|
|
// failure and returns no libraries for it — identical to a genuinely empty
|
|
// server. Keying loaded-state on fetch *success* keeps a transiently
|
|
// failed server out of _loadedServerIds so it reloads instead of staying
|
|
// missing until a profile re-switch/restart.
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')])
|
|
..error = Exception('transient');
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
manager.debugRegisterClientForTesting(clientB);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'A', 'B'});
|
|
// A loaded; B's fetch failed, so it is absent and must not be recorded.
|
|
expect(p.libraries.map((l) => l.title), ['Movies A']);
|
|
|
|
// B recovers. The same online set must now reload it rather than treating
|
|
// B as already covered.
|
|
clientB.error = null;
|
|
await p.syncToOnlineServers({'A', 'B'});
|
|
|
|
expect(p.libraries.map((l) => l.title), containsAll(<String>['Movies A', 'Shows B']));
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('does not reload when the online set shrinks', () async {
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
manager.debugRegisterClientForTesting(clientB);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'A', 'B'});
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
|
|
// A drops; the visible online set is now a subset of what we loaded.
|
|
await p.syncToOnlineServers({'B'});
|
|
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
expect(clientB.fetchLibrariesCalls, 1);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a zero-library server is marked loaded and does not retrigger', () async {
|
|
final manager = MultiServerManager();
|
|
final clientC = _FakeClient(serverId: ServerId('C'), libraries: const []);
|
|
manager.debugRegisterClientForTesting(clientC);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'C'});
|
|
expect(p.hasLoaded, isTrue);
|
|
expect(p.libraries, isEmpty);
|
|
expect(clientC.fetchLibrariesCalls, 1);
|
|
|
|
// Tracking the requested set (not deriving from loaded libraries) is what
|
|
// stops a zero-library server from looking "never loaded" and reloading
|
|
// on every status emission.
|
|
await p.syncToOnlineServers({'C'});
|
|
expect(clientC.fetchLibrariesCalls, 1);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a server appearing mid-load is still picked up', () async {
|
|
final manager = MultiServerManager();
|
|
final gate = Completer<void>();
|
|
final clientA = _FakeClient(
|
|
serverId: ServerId('A'),
|
|
libraries: [_serverLib(ServerId('A'), '1', 'Movies A')],
|
|
gate: gate.future,
|
|
);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
// First load starts and suspends on A's gated fetch.
|
|
final inFlight = p.syncToOnlineServers({'A'});
|
|
|
|
// B comes online before the first load completes.
|
|
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
|
|
manager.debugRegisterClientForTesting(clientB);
|
|
unawaited(p.syncToOnlineServers({'A', 'B'})); // queued behind the in-flight pass
|
|
|
|
gate.complete();
|
|
await inFlight; // resolves after the replayed pass covering {A, B}
|
|
|
|
expect(p.libraries.map((l) => l.title), containsAll(<String>['Movies A', 'Shows B']));
|
|
expect(clientA.fetchLibrariesCalls, 2, reason: 'a second pass runs for the larger set');
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('online-server deltas arriving mid-pass are unioned into one trailing pass', () async {
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
await p.syncToOnlineServers({'A'});
|
|
|
|
final gate = Completer<void>();
|
|
final clientB = _FakeClient(
|
|
serverId: ServerId('B'),
|
|
libraries: [_serverLib(ServerId('B'), '1', 'Shows B')],
|
|
gate: gate.future,
|
|
);
|
|
manager.debugRegisterClientForTesting(clientB);
|
|
final firstDelta = p.syncToOnlineServers({'A', 'B'});
|
|
expect(clientB.fetchLibrariesCalls, 1);
|
|
|
|
final clientC = _FakeClient(serverId: ServerId('C'), libraries: [_serverLib(ServerId('C'), '1', 'Movies C')]);
|
|
manager.debugRegisterClientForTesting(clientC);
|
|
final trailingDelta = p.syncToOnlineServers({'A', 'B', 'C'});
|
|
|
|
gate.complete();
|
|
await Future.wait([firstDelta, trailingDelta]);
|
|
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
expect(clientB.fetchLibrariesCalls, 1, reason: 'the trailing delta drops ids committed by the active pass');
|
|
expect(clientC.fetchLibrariesCalls, 1);
|
|
expect(p.libraries.map((library) => library.title), containsAll(['Movies A', 'Shows B', 'Movies C']));
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a coalesced call gets its trailing pass after the in-flight pass fails', () async {
|
|
final manager = MultiServerManager();
|
|
final gate = Completer<void>();
|
|
final clientA = _FakeClient(
|
|
serverId: ServerId('A'),
|
|
libraries: [_serverLib(ServerId('A'), '1', 'Movies A')],
|
|
gate: gate.future,
|
|
errorForCall: (call) => call == 1
|
|
? MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'first pass cancelled')
|
|
: null,
|
|
);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
final first = p.loadLibraries();
|
|
final coalesced = p.loadLibraries();
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
|
|
gate.complete();
|
|
await Future.wait([first, coalesced]);
|
|
|
|
expect(clientA.fetchLibrariesCalls, 2);
|
|
expect(p.hasLoaded, isTrue);
|
|
expect(p.libraries.map((library) => library.title), ['Movies A']);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('dispose during an in-flight coalesced load prevents trailing work and commits', () async {
|
|
final manager = MultiServerManager();
|
|
final gate = Completer<void>();
|
|
final clientA = _FakeClient(
|
|
serverId: ServerId('A'),
|
|
libraries: [_serverLib(ServerId('A'), '1', 'Movies A')],
|
|
gate: gate.future,
|
|
);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
var notifications = 0;
|
|
p.addListener(() => notifications++);
|
|
|
|
final first = p.loadLibraries();
|
|
final coalesced = p.loadLibraries();
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
expect(notifications, 1, reason: 'the initial loading state was published before disposal');
|
|
|
|
p.dispose();
|
|
gate.complete();
|
|
await Future.wait([first, coalesced]);
|
|
|
|
expect(clientA.fetchLibrariesCalls, 1, reason: 'the queued trailing pass was discarded');
|
|
expect(p.libraries, isEmpty, reason: 'the completed fetch was not committed after disposal');
|
|
expect(notifications, 1);
|
|
|
|
await p.loadLibraries();
|
|
await p.syncToOnlineServers({'A'});
|
|
expect(clientA.fetchLibrariesCalls, 1, reason: 'post-dispose entry points are no-ops');
|
|
|
|
manager.dispose();
|
|
});
|
|
|
|
test('clear() resets tracking so the next sync reloads', () async {
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.syncToOnlineServers({'A'});
|
|
expect(clientA.fetchLibrariesCalls, 1);
|
|
|
|
p.clear();
|
|
expect(p.hasLoaded, isFalse);
|
|
|
|
await p.syncToOnlineServers({'A'});
|
|
expect(clientA.fetchLibrariesCalls, 2);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a first load disrupted by cancellations stays loading instead of flashing empty', () async {
|
|
// The sign-in empty-flash regression: a rebind tore the client down
|
|
// mid-fetch, the aborted pass used to commit loaded-empty, and the
|
|
// sidebar flashed "no libraries found" until the follow-up load landed.
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')])
|
|
..error = MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing');
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.loadLibraries();
|
|
|
|
expect(p.isLoading, isTrue);
|
|
expect(p.hasLoaded, isFalse);
|
|
expect(p.errorMessage, isNull);
|
|
|
|
// The guaranteed follow-up load (binding-settle prime / next status
|
|
// emission) lands the real list.
|
|
clientA.error = null;
|
|
await p.loadLibraries();
|
|
expect(p.hasLoaded, isTrue);
|
|
expect(p.libraries.map((l) => l.title), ['Movies A']);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a zero-success first load during profile binding stays loading', () async {
|
|
// The timeout-during-bind window: every fetch failed while the binder
|
|
// was still wiring servers, with no cancellation marker.
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')])
|
|
..error = Exception('probe timed out');
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
var binding = true;
|
|
final p = LibrariesProvider(isProfileBinding: () => binding)..initialize(DataAggregationService(manager));
|
|
|
|
await p.loadLibraries();
|
|
expect(p.isLoading, isTrue);
|
|
expect(p.hasLoaded, isFalse);
|
|
|
|
binding = false;
|
|
clientA.error = null;
|
|
await p.loadLibraries();
|
|
expect(p.hasLoaded, isTrue);
|
|
expect(p.libraries.map((l) => l.title), ['Movies A']);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a settled zero-success first load still commits loaded-empty', () async {
|
|
// Locks the no-eternal-spinner constraint: a genuinely dead server
|
|
// outside any disruption window keeps today's empty state.
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'))..error = Exception('connection refused');
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.loadLibraries();
|
|
|
|
expect(p.hasLoaded, isTrue);
|
|
expect(p.libraries, isEmpty);
|
|
expect(p.errorMessage, isNull);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('a totally failed silent refresh keeps the last good list', () async {
|
|
// Regression (pre-existing wipe bug): a reload-in-place where every
|
|
// server fails without throwing used to replace the list with [] —
|
|
// blanking the sidebar on a transient outage.
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
|
|
await p.loadLibraries();
|
|
expect(p.libraries.map((l) => l.title), ['Movies A']);
|
|
|
|
clientA.error = Exception('offline');
|
|
await p.loadLibraries();
|
|
expect(p.hasLoaded, isTrue);
|
|
expect(p.libraries.map((l) => l.title), ['Movies A']);
|
|
|
|
// The kept list does not count as covering the failed server — the
|
|
// next sync refetches it.
|
|
clientA.error = null;
|
|
final callsBefore = clientA.fetchLibrariesCalls;
|
|
await p.syncToOnlineServers({'A'});
|
|
expect(clientA.fetchLibrariesCalls, callsBefore + 1);
|
|
|
|
p.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('online-servers listener is removed on dispose', () {
|
|
final manager = MultiServerManager();
|
|
final multiServer = testMultiServerProvider(manager);
|
|
|
|
final before = multiServer.onlineServersListenerCount;
|
|
final scoped = LibrariesProvider(multiServer: multiServer);
|
|
expect(multiServer.onlineServersListenerCount, before + 1);
|
|
|
|
scoped.dispose();
|
|
expect(multiServer.onlineServersListenerCount, before);
|
|
|
|
multiServer.dispose();
|
|
manager.dispose();
|
|
});
|
|
|
|
test('is a no-op for an empty set or before initialize', () async {
|
|
final manager = MultiServerManager();
|
|
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
|
|
manager.debugRegisterClientForTesting(clientA);
|
|
|
|
// Empty set on an initialized provider.
|
|
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
|
|
await p.syncToOnlineServers(<String>{});
|
|
expect(p.loadState, LibrariesLoadState.initial);
|
|
expect(clientA.fetchLibrariesCalls, 0);
|
|
p.dispose();
|
|
|
|
// Non-empty set on an uninitialized provider.
|
|
final p2 = LibrariesProvider();
|
|
var notified = 0;
|
|
p2.addListener(() => notified++);
|
|
await p2.syncToOnlineServers({'A'});
|
|
expect(p2.loadState, LibrariesLoadState.initial);
|
|
expect(notified, 0);
|
|
p2.dispose();
|
|
|
|
manager.dispose();
|
|
});
|
|
});
|
|
}
|