test: stabilize deterministic integration coverage
This commit is contained in:
@@ -22,7 +22,6 @@ void main() {
|
||||
|
||||
tearDown(() {
|
||||
SettingsService.resetForTesting();
|
||||
resetSharedPreferencesForTest();
|
||||
});
|
||||
|
||||
testWidgets('only custom players expose a focusable delete action', (tester) async {
|
||||
|
||||
@@ -39,18 +39,22 @@ class _DelayedCountingHttpClient extends http.BaseClient {
|
||||
|
||||
void main() {
|
||||
late Directory tmpRoot;
|
||||
late PathProviderPlatform previousPathProvider;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
tmpRoot = await Directory.systemTemp.createTemp('download_artwork_service_test_');
|
||||
previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||
});
|
||||
|
||||
|
||||
@@ -214,10 +214,13 @@ void main() {
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_');
|
||||
final previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
addTearDown(() async {
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||
});
|
||||
|
||||
@@ -308,10 +311,13 @@ void main() {
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_delete_test_');
|
||||
final previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
addTearDown(() async {
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||
});
|
||||
|
||||
@@ -606,6 +612,7 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_');
|
||||
final previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
|
||||
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
||||
@@ -710,6 +717,8 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe
|
||||
await db.close();
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -719,6 +728,7 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind,
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_');
|
||||
final previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
|
||||
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
||||
@@ -796,6 +806,8 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind,
|
||||
await db.close();
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,22 @@ import '../test_helpers/media_items.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tmpRoot;
|
||||
late PathProviderPlatform previousPathProvider;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
tmpRoot = await Directory.systemTemp.createTemp('dss_test_');
|
||||
previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||
if (await tmpRoot.exists()) {
|
||||
await tmpRoot.delete(recursive: true);
|
||||
}
|
||||
|
||||
@@ -14,23 +14,6 @@ import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
// NOTE on coverage scope:
|
||||
// `EpisodeNavigationService` has two methods:
|
||||
//
|
||||
// 1. `loadAdjacentEpisodes` — pure-ish: reads PlaybackStateProvider, asks for
|
||||
// next/prev episode, wraps the result. The interesting branch is the
|
||||
// "no queue active" short-circuit, which we exercise without any client
|
||||
// or network because PlaybackStateProvider can be constructed bare.
|
||||
//
|
||||
// 2. `navigateToEpisode` — performs full navigation through
|
||||
// [navigateToVideoPlayer], which depends on a Navigator, a
|
||||
// DownloadProvider, a MultiServerProvider, and the [SettingsService]
|
||||
// singleton. Skipped: not unit-testable without recreating the entire
|
||||
// app shell.
|
||||
//
|
||||
// We also cover the [AdjacentEpisodes] data class invariants since that's
|
||||
// the public surface callers depend on.
|
||||
|
||||
MediaItem _meta(String id, {String? title}) =>
|
||||
testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id');
|
||||
|
||||
|
||||
@@ -30,30 +30,13 @@ JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
|
||||
|
||||
JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: _jellyfinConnection(userId));
|
||||
|
||||
// NOTE on coverage scope:
|
||||
// [MultiServerManager.addServer] / `connectToAllServers` / `_createClientForServer`
|
||||
// all instantiate a real `PlexClient` via `findBestWorkingConnection`, which
|
||||
// performs live HTTP calls to a Plex Media Server. The manager does NOT expose
|
||||
// a fake `PlexClient` factory, so per the task brief we don't fake the network
|
||||
// here.
|
||||
//
|
||||
// The tests below cover the orchestration logic that DOESN'T require a network:
|
||||
// - construction & initial state
|
||||
// - `removeServer` (pure local-map mutation)
|
||||
// - `updateServerStatus` + status-stream emissions
|
||||
// - `disconnectAll` / `dispose` lifecycle (no connectivity sub started, so
|
||||
// this verifies the no-op path for the subscription cancel)
|
||||
//
|
||||
// The Jellyfin exhaustion path IS covered ('endpoint exhaustion verification'
|
||||
// group): the health-probe confirmation, offline flip + reconnection, and the
|
||||
// debounce-driven retry loop, via the registered fake Jellyfin client.
|
||||
//
|
||||
// What is NOT covered here (would need a fake PlexClient factory):
|
||||
// - `addServer` success path
|
||||
// - `connectToAllServers` outcome map
|
||||
// - `checkServerHealth` health-probe sweep
|
||||
// - `_reoptimizeServer` endpoint promotion
|
||||
// - `startNetworkMonitoring` connectivity-listener path
|
||||
// Coverage includes status and lifecycle changes, endpoint exhaustion,
|
||||
// in-place Plex token refresh, Jellyfin reuse/update, and selected
|
||||
// registered-Jellyfin `checkServerHealth` outcomes. First-time
|
||||
// `addPlexAccount` and `refreshTokensForProfile` fallback construction through
|
||||
// `_createClientForServer`, Plex and mixed-client health/coalescing,
|
||||
// `_reoptimizeServer`, and `_startNetworkMonitoring` subscription/debounce
|
||||
// behavior remain outside this suite.
|
||||
|
||||
void main() {
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
@@ -23,27 +23,13 @@ import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
// NOTE on coverage scope:
|
||||
// The actual sync-to-server path (`syncPendingItems`, `syncWatchStatesFromServer`,
|
||||
// `_performBidirectionalSync`) all reach into a real `PlexClient` via the
|
||||
// injected `MultiServerManager`. Per the task brief we do NOT exercise those
|
||||
// paths here — they require either a fake `PlexClient` factory or live HTTP.
|
||||
//
|
||||
// What IS covered:
|
||||
// - Initial state on a fresh service.
|
||||
// - `queueMarkWatched` / `queueMarkUnwatched` — local DB persistence.
|
||||
// - `getLocalWatchStatus` / `getLocalViewOffset` — local resolution.
|
||||
// - `getPendingSyncCount` — DB-side count.
|
||||
// - `clearAll` — local wipe.
|
||||
// - `dispose` — listener cleanup on the offline-mode source.
|
||||
// - Connectivity listener attachment via `startConnectivityMonitoring`.
|
||||
//
|
||||
// What is NOT covered (would need a fake PlexClient factory):
|
||||
// - `_performBidirectionalSync` (online path)
|
||||
// - `syncPendingItems` outcome map
|
||||
// - `syncWatchStatesFromServer` cache-write logic
|
||||
// - `getWatchedThreshold`'s "online client preference" branch — only the
|
||||
// SettingsService cached + default branches are testable here.
|
||||
// Direct `syncPendingItems` coverage exercises retry retention, Plex/Jellyfin
|
||||
// progress replay, profile interruption, and scoped Jellyfin routing. Direct
|
||||
// `syncWatchStatesFromServer` coverage exercises active-profile and
|
||||
// active-scope routing plus selected watched outcomes. Trigger coalescing and
|
||||
// throttle sequencing inside `_performBidirectionalSync`, direct cache-row and
|
||||
// refresh-callback assertions, and non-default watched-threshold sources remain
|
||||
// outside this suite.
|
||||
|
||||
/// Minimal [OfflineModeSource] that lets tests flip the offline flag and
|
||||
/// observe `addListener`/`removeListener` traffic via the protected
|
||||
|
||||
@@ -30,12 +30,14 @@ void main() {
|
||||
|
||||
late AppDatabase db;
|
||||
late Directory tmpRoot;
|
||||
late PathProviderPlatform previousPathProvider;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_');
|
||||
previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
@@ -46,6 +48,8 @@ void main() {
|
||||
await db.close();
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||
if (await tmpRoot.exists()) {
|
||||
await tmpRoot.delete(recursive: true);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.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/shared_preferences_platform_interface.dart';
|
||||
|
||||
/// Reset shared-prefs platform mocks AND the cached singleton instances.
|
||||
/// Call from `setUp` so each test starts with a clean slate.
|
||||
void resetSharedPreferencesForTest({Map<String, Object> initialAsync = const {}}) {
|
||||
final previousLegacyPlatform = SharedPreferencesStorePlatform.instance;
|
||||
final previousAsyncPlatform = SharedPreferencesAsyncPlatform.instance;
|
||||
addTearDown(() {
|
||||
SharedPreferencesStorePlatform.instance = previousLegacyPlatform;
|
||||
SharedPreferencesAsyncPlatform.instance = previousAsyncPlatform;
|
||||
SharedPreferences.resetStatic();
|
||||
SettingsService.resetForTesting();
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
});
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
SharedPreferencesAsyncPlatform.instance = initialAsync.isEmpty
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.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/shared_preferences_platform_interface.dart';
|
||||
|
||||
import 'prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late SharedPreferencesStorePlatform originalLegacyPlatform;
|
||||
late SharedPreferencesAsyncPlatform? originalAsyncPlatform;
|
||||
late SharedPreferencesStorePlatform sentinelLegacyPlatform;
|
||||
late SharedPreferencesAsyncPlatform sentinelAsyncPlatform;
|
||||
|
||||
setUp(() {
|
||||
originalLegacyPlatform = SharedPreferencesStorePlatform.instance;
|
||||
originalAsyncPlatform = SharedPreferencesAsyncPlatform.instance;
|
||||
sentinelLegacyPlatform = InMemorySharedPreferencesStore.withData({'flutter.restored_legacy': true});
|
||||
sentinelAsyncPlatform = InMemorySharedPreferencesAsync.withData({'restored_async': true});
|
||||
SharedPreferencesStorePlatform.instance = sentinelLegacyPlatform;
|
||||
SharedPreferencesAsyncPlatform.instance = sentinelAsyncPlatform;
|
||||
SharedPreferences.resetStatic();
|
||||
SettingsService.resetForTesting();
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
try {
|
||||
expect(SharedPreferencesStorePlatform.instance, same(sentinelLegacyPlatform));
|
||||
expect(SharedPreferencesAsyncPlatform.instance, same(sentinelAsyncPlatform));
|
||||
expect((await SharedPreferences.getInstance()).getBool('restored_legacy'), isTrue);
|
||||
expect(await SharedPreferencesAsync().getBool('restored_async'), isTrue);
|
||||
final restoredSettings = await SettingsService.getInstance();
|
||||
expect(restoredSettings.prefs.getBool('temporary_value'), isNull);
|
||||
} finally {
|
||||
SharedPreferencesStorePlatform.instance = originalLegacyPlatform;
|
||||
SharedPreferencesAsyncPlatform.instance = originalAsyncPlatform;
|
||||
SharedPreferences.resetStatic();
|
||||
SettingsService.resetForTesting();
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
}
|
||||
});
|
||||
|
||||
test('restores both platform singletons and invalidates cached services', () async {
|
||||
resetSharedPreferencesForTest();
|
||||
|
||||
final temporarySettings = await SettingsService.getInstance();
|
||||
await temporarySettings.prefs.setBool('temporary_value', true);
|
||||
expect(temporarySettings.prefs.getBool('temporary_value'), isTrue);
|
||||
expect(SharedPreferencesStorePlatform.instance, isNot(same(sentinelLegacyPlatform)));
|
||||
expect(SharedPreferencesAsyncPlatform.instance, isNot(same(sentinelAsyncPlatform)));
|
||||
});
|
||||
|
||||
test('nested resets restore the immediately preceding platform pair', () {
|
||||
resetSharedPreferencesForTest(initialAsync: const {'first': true});
|
||||
final firstLegacyPlatform = SharedPreferencesStorePlatform.instance;
|
||||
final firstAsyncPlatform = SharedPreferencesAsyncPlatform.instance;
|
||||
|
||||
addTearDown(() {
|
||||
expect(SharedPreferencesStorePlatform.instance, same(firstLegacyPlatform));
|
||||
expect(SharedPreferencesAsyncPlatform.instance, same(firstAsyncPlatform));
|
||||
});
|
||||
|
||||
resetSharedPreferencesForTest(initialAsync: const {'second': true});
|
||||
expect(SharedPreferencesStorePlatform.instance, isNot(same(firstLegacyPlatform)));
|
||||
expect(SharedPreferencesAsyncPlatform.instance, isNot(same(firstAsyncPlatform)));
|
||||
});
|
||||
}
|
||||
+188
-112
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/utils/endpoint_race.dart';
|
||||
@@ -30,145 +31,220 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
Future<_Result> resultAfter(String url, Duration delay, {required bool ok}) async {
|
||||
await Future<void>.delayed(delay);
|
||||
return (url: url, ok: ok);
|
||||
}
|
||||
test('healthy cached endpoint wins within the head start without racing', () {
|
||||
fakeAsync((async) {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||
late Completer<_Result> cachedGate;
|
||||
|
||||
test('healthy cached endpoint wins within the head start without racing', () async {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = await race(
|
||||
candidates: ['a', 'cached'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
return resultAfter(url, const Duration(milliseconds: 10), ok: true);
|
||||
},
|
||||
).toList();
|
||||
race(
|
||||
candidates: ['a', 'cached'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
final gate = Completer<_Result>();
|
||||
if (url == 'cached') cachedGate = gate;
|
||||
return gate.future;
|
||||
},
|
||||
).listen(selections.add);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(selections.first.phase, EndpointRacePhase.first);
|
||||
expect(selections.first.candidate, 'cached');
|
||||
expect(selections.first.fromPreferred, isTrue);
|
||||
expect(probeCounts['cached'], 1);
|
||||
// The race never started; only the phase-2 measure touches other URLs.
|
||||
expect(probeCounts.containsKey('a'), isFalse);
|
||||
expect(probeCounts, {'cached': 1});
|
||||
cachedGate.complete((url: 'cached', ok: true));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(selections.first.phase, EndpointRacePhase.first);
|
||||
expect(selections.first.candidate, 'cached');
|
||||
expect(selections.first.fromPreferred, isTrue);
|
||||
expect(probeCounts.containsKey('a'), isFalse);
|
||||
|
||||
async.elapse(headStart);
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
test('stale-slow cached endpoint overlaps the race instead of serially blocking it', () async {
|
||||
final probeCounts = <String, int>{};
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final firstTimes = <int>[];
|
||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||
await for (final selection in race(
|
||||
candidates: ['fast', 'cached'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
return resultAfter(
|
||||
url,
|
||||
url == 'cached' ? const Duration(milliseconds: 250) : const Duration(milliseconds: 10),
|
||||
ok: url != 'cached',
|
||||
);
|
||||
},
|
||||
)) {
|
||||
selections.add(selection);
|
||||
firstTimes.add(stopwatch.elapsedMilliseconds);
|
||||
}
|
||||
test('stale-slow cached endpoint overlaps the race instead of serially blocking it', () {
|
||||
fakeAsync((async) {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||
late Completer<_Result> cachedGate;
|
||||
late Completer<_Result> fastGate;
|
||||
|
||||
expect(selections.first.candidate, 'fast');
|
||||
expect(selections.first.fromPreferred, isFalse);
|
||||
// Emitted shortly after the head start — not after the cached probe's
|
||||
// full budget (the pre-change serial behavior).
|
||||
expect(firstTimes.first, lessThan(200));
|
||||
// The pending cached probe was merged into the race, not re-fired.
|
||||
expect(probeCounts['cached'], 1);
|
||||
race(
|
||||
candidates: ['fast', 'cached'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
final gate = Completer<_Result>();
|
||||
if (url == 'cached') {
|
||||
cachedGate = gate;
|
||||
} else {
|
||||
fastGate = gate;
|
||||
}
|
||||
return gate.future;
|
||||
},
|
||||
).listen(selections.add);
|
||||
async.flushMicrotasks();
|
||||
|
||||
// Let the still-pending cached probe finish inside the test body.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
expect(probeCounts, {'cached': 1});
|
||||
async.elapse(headStart - const Duration(milliseconds: 1));
|
||||
async.flushMicrotasks();
|
||||
expect(probeCounts, {'cached': 1});
|
||||
|
||||
async.elapse(const Duration(milliseconds: 1));
|
||||
async.flushMicrotasks();
|
||||
expect(probeCounts, {'cached': 1, 'fast': 1});
|
||||
|
||||
fastGate.complete((url: 'fast', ok: true));
|
||||
async.flushMicrotasks();
|
||||
expect(selections.first.phase, EndpointRacePhase.first);
|
||||
expect(selections.first.candidate, 'fast');
|
||||
expect(selections.first.fromPreferred, isFalse);
|
||||
expect(probeCounts['cached'], 1);
|
||||
|
||||
cachedGate.complete((url: 'cached', ok: false));
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
test('cached endpoint that answers after the head start still wins when first', () async {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = await race(
|
||||
candidates: ['slow', 'cached'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
return resultAfter(
|
||||
url,
|
||||
url == 'cached' ? const Duration(milliseconds: 120) : const Duration(milliseconds: 350),
|
||||
ok: true,
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
test('cached endpoint that answers after the head start still wins when first', () {
|
||||
fakeAsync((async) {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||
late Completer<_Result> cachedGate;
|
||||
late Completer<_Result> slowGate;
|
||||
|
||||
expect(selections.first.candidate, 'cached');
|
||||
expect(selections.first.fromPreferred, isTrue);
|
||||
expect(probeCounts['cached'], 1);
|
||||
race(
|
||||
candidates: ['slow', 'cached'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
final gate = Completer<_Result>();
|
||||
if (url == 'cached') {
|
||||
cachedGate = gate;
|
||||
} else {
|
||||
slowGate = gate;
|
||||
}
|
||||
return gate.future;
|
||||
},
|
||||
).listen(selections.add);
|
||||
async.flushMicrotasks();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
expect(probeCounts, {'cached': 1});
|
||||
async.elapse(headStart);
|
||||
async.flushMicrotasks();
|
||||
expect(probeCounts, {'cached': 1, 'slow': 1});
|
||||
|
||||
cachedGate.complete((url: 'cached', ok: true));
|
||||
async.flushMicrotasks();
|
||||
expect(selections.first.candidate, 'cached');
|
||||
expect(selections.first.fromPreferred, isTrue);
|
||||
expect(probeCounts['cached'], 1);
|
||||
|
||||
slowGate.complete((url: 'slow', ok: true));
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
test('cached endpoint failing within the head start falls back to a fresh race', () async {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = await race(
|
||||
candidates: ['cached', 'alt'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
return resultAfter(url, const Duration(milliseconds: 10), ok: url == 'alt');
|
||||
},
|
||||
).toList();
|
||||
test('cached endpoint failing within the head start falls back to a fresh race', () {
|
||||
fakeAsync((async) {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||
final cachedGates = <Completer<_Result>>[];
|
||||
late Completer<_Result> altGate;
|
||||
|
||||
expect(selections.first.candidate, 'alt');
|
||||
expect(selections.first.fromPreferred, isFalse);
|
||||
// Fast-fail keeps today's semantics: the cached URL re-races as a
|
||||
// normal candidate (one probe up front, one inside the race).
|
||||
expect(probeCounts['cached'], 2);
|
||||
race(
|
||||
candidates: ['cached', 'alt'],
|
||||
preferred: 'cached',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
final gate = Completer<_Result>();
|
||||
if (url == 'cached') {
|
||||
cachedGates.add(gate);
|
||||
} else {
|
||||
altGate = gate;
|
||||
}
|
||||
return gate.future;
|
||||
},
|
||||
).listen(selections.add);
|
||||
async.flushMicrotasks();
|
||||
|
||||
cachedGates.single.complete((url: 'cached', ok: false));
|
||||
async.flushMicrotasks();
|
||||
expect(probeCounts, {'cached': 2, 'alt': 1});
|
||||
|
||||
cachedGates.last.complete((url: 'cached', ok: false));
|
||||
altGate.complete((url: 'alt', ok: true));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(selections.first.candidate, 'alt');
|
||||
expect(selections.first.fromPreferred, isFalse);
|
||||
expect(probeCounts['cached'], 2);
|
||||
|
||||
async.elapse(headStart);
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
test('preferred URL not among candidates skips the cached probe entirely', () async {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = await race(
|
||||
candidates: ['a', 'b'],
|
||||
preferred: 'custom-url',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
return resultAfter(url, const Duration(milliseconds: 10), ok: url == 'a');
|
||||
},
|
||||
).toList();
|
||||
test('preferred URL not among candidates skips the cached probe entirely', () {
|
||||
fakeAsync((async) {
|
||||
final probeCounts = <String, int>{};
|
||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||
final gates = <String, Completer<_Result>>{};
|
||||
|
||||
expect(selections.first.candidate, 'a');
|
||||
expect(selections.first.fromPreferred, isFalse);
|
||||
expect(probeCounts.containsKey('custom-url'), isFalse);
|
||||
race(
|
||||
candidates: ['a', 'b'],
|
||||
preferred: 'custom-url',
|
||||
probe: (url) {
|
||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||
return (gates[url] = Completer<_Result>()).future;
|
||||
},
|
||||
).listen(selections.add);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(probeCounts.containsKey('custom-url'), isFalse);
|
||||
gates['a']!.complete((url: 'a', ok: true));
|
||||
gates['b']!.complete((url: 'b', ok: false));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(selections.first.candidate, 'a');
|
||||
expect(selections.first.fromPreferred, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('emits nothing when every candidate fails', () async {
|
||||
final selections = await race(
|
||||
candidates: ['a', 'b'],
|
||||
preferred: 'a',
|
||||
probe: (url) => resultAfter(url, const Duration(milliseconds: 10), ok: false),
|
||||
probe: (url) async => (url: url, ok: false),
|
||||
).toList();
|
||||
|
||||
expect(selections, isEmpty);
|
||||
});
|
||||
|
||||
test('phase 2 still promotes the selector-best endpoint', () async {
|
||||
final selections = await race(
|
||||
candidates: ['quick', 'better'],
|
||||
probe: (url) => resultAfter(
|
||||
url,
|
||||
url == 'quick' ? const Duration(milliseconds: 10) : const Duration(milliseconds: 80),
|
||||
ok: true,
|
||||
),
|
||||
measure: (url) async => (url: url, ok: true),
|
||||
selectBest: (results) => 'better',
|
||||
).toList();
|
||||
test('phase 2 still promotes the selector-best endpoint', () {
|
||||
fakeAsync((async) {
|
||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||
final gates = <String, Completer<_Result>>{};
|
||||
|
||||
expect(selections, hasLength(2));
|
||||
expect(selections.first.phase, EndpointRacePhase.first);
|
||||
expect(selections.first.candidate, 'quick');
|
||||
expect(selections.last.phase, EndpointRacePhase.best);
|
||||
expect(selections.last.candidate, 'better');
|
||||
race(
|
||||
candidates: ['quick', 'better'],
|
||||
probe: (url) => (gates[url] = Completer<_Result>()).future,
|
||||
measure: (url) async => (url: url, ok: true),
|
||||
selectBest: (results) => 'better',
|
||||
).listen(selections.add);
|
||||
async.flushMicrotasks();
|
||||
|
||||
gates['quick']!.complete((url: 'quick', ok: true));
|
||||
async.flushMicrotasks();
|
||||
gates['better']!.complete((url: 'better', ok: true));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(selections, hasLength(2));
|
||||
expect(selections.first.phase, EndpointRacePhase.first);
|
||||
expect(selections.first.candidate, 'quick');
|
||||
expect(selections.last.phase, EndpointRacePhase.best);
|
||||
expect(selections.last.candidate, 'better');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user