fix(screens): claim the mandatory profile picker before awaiting and skip the full-screen loader after playback

The mandatory profile picker checked its re-entry flag before several awaits, so an initialization notification could stack two requireSelection routes; the flag is now claimed before the first await and the late-profiles edge only fires on a real transition. Returning from playback also reran the full metadata loader, replacing the whole detail screen with a spinner and resetting the selected season; playback returns now use the non-loading watch-state refresh.
This commit is contained in:
edde746
2026-08-21 19:23:41 +02:00
parent 7d0e04bcc0
commit 0f5cd11c08
5 changed files with 315 additions and 38 deletions
+169
View File
@@ -1,9 +1,22 @@
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/screens/main_screen.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import '../test_helpers/prefs.dart';
void main() {
test('side navigation pushes stable foreground off-screen while temporarily expanded', () {
const viewportWidth = 1280.0;
@@ -297,4 +310,160 @@ void main() {
await tester.pumpAndSettle();
expect(left(), closeTo(-SideNavigationRailState.expandedWidth, 0.001));
});
group('runInitialProfilePrompt', () {
Future<_PromptFixture> makeFixture() async {
resetSharedPreferencesForTest();
final db = AppDatabase.forTesting(NativeDatabase.memory());
final connections = ConnectionRegistry(db);
final profileConnections = ProfileConnectionRegistry(db);
final profiles = ProfileRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final active = _GatedInitializeActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
addTearDown(() async {
await active.resetForTesting();
active.dispose();
await plexHome.dispose();
await db.close();
});
return _PromptFixture(profiles: profiles, storage: storage, active: active);
}
test('mid-initialize synchronous notify pushes exactly one requireSelection picker', () async {
final fixture = await makeFixture();
// Two selectable profiles, none active: the prompt must push the picker.
await fixture.profiles.upsert(Profile.local(id: 'p1', displayName: 'One', createdAt: DateTime(2026, 1, 1)));
await fixture.profiles.upsert(Profile.local(id: 'p2', displayName: 'Two', createdAt: DateTime(2026, 1, 2)));
var claimed = false;
var pushes = 0;
final pickerPopped = Completer<void>();
final runs = <Future<void>>[];
Future<void> run() => runInitialProfilePrompt(
activeProfile: fixture.active,
claimPrompt: () {
if (claimed) return false;
claimed = true;
return true;
},
releasePrompt: () => claimed = false,
isMounted: () => true,
isOfflineMode: false,
hasConnections: () async => fail('zero-profiles settle branch must not run'),
settleSession: () async => fail('zero-profiles settle branch must not run'),
pushProfileSelection: () async {
pushes++;
// The real picker route stays up until the user selects a profile.
await pickerPopped.future;
},
);
// The late-profiles re-arm: any provider notification landing while the
// first prompt is parked on initialize() re-enters the prompt, exactly
// like _onActiveProfileChanged does.
fixture.active.addListener(() => runs.add(run()));
// Parks on the gated initialize after its synchronous notify re-entered.
runs.add(run());
expect(runs, hasLength(2), reason: 'the gated initialize notified synchronously');
fixture.active.initializeGate.complete();
await pumpEventQueue();
expect(pushes, 1);
// A notification landing while the picker route is up (e.g. the
// connection watcher) must not stack a second requireSelection picker.
fixture.active.emitExternalNotify();
await pumpEventQueue();
expect(pushes, 1);
pickerPopped.complete();
await Future.wait(runs);
expect(pushes, 1);
expect(claimed, isFalse, reason: 'claim released after the picker resolves');
});
test('non-push exit releases the double-push claim', () async {
final fixture = await makeFixture();
// One profile, already active, "require selection on open" off: the
// prompt must decline to push and release its claim so a later genuine
// prompt is still possible.
await fixture.profiles.upsert(Profile.local(id: 'p1', displayName: 'One', createdAt: DateTime(2026, 1, 1)));
await fixture.storage.setActiveProfileId('p1');
fixture.active.initializeGate.complete();
var claimed = false;
var pushes = 0;
Future<void> run() => runInitialProfilePrompt(
activeProfile: fixture.active,
claimPrompt: () {
if (claimed) return false;
claimed = true;
return true;
},
releasePrompt: () => claimed = false,
isMounted: () => true,
isOfflineMode: false,
hasConnections: () async => fail('zero-profiles settle branch must not run'),
settleSession: () async => fail('zero-profiles settle branch must not run'),
pushProfileSelection: () async => pushes++,
);
await run();
expect(pushes, 0);
expect(claimed, isFalse, reason: 'a non-push exit must release the claim');
// The released claim keeps future prompts reachable.
await run();
expect(pushes, 0);
expect(claimed, isFalse);
});
});
}
class _PromptFixture {
_PromptFixture({required this.profiles, required this.storage, required this.active});
final ProfileRegistry profiles;
final StorageService storage;
final _GatedInitializeActiveProfileProvider active;
}
/// [ActiveProfileProvider] whose [initialize] parks on [initializeGate] after
/// notifying — the fresh sign-in shape where the provider's connection watcher
/// notifies while MainScreen's prompt is still awaiting initialize().
class _GatedInitializeActiveProfileProvider extends ActiveProfileProvider {
_GatedInitializeActiveProfileProvider({
required super.registry,
required super.plexHome,
required super.connections,
required super.profileConnections,
required super.storage,
});
final Completer<void> initializeGate = Completer<void>();
@override
Future<void> initialize() async {
notifyListeners();
await initializeGate.future;
await super.initialize();
}
/// A notification from outside the prompt (e.g. a connection table write).
void emitExternalNotify() => notifyListeners();
}
@@ -1113,6 +1113,65 @@ void main() {
expect(find.text('S1E1'), findsNothing);
});
testWidgets('returning from playback refreshes watch state without the full-screen loader', (tester) async {
// The post-playback refresh used to re-run _loadFullMetadata, which
// raises _isLoadingMetadata — build then swaps the whole detail subtree
// for a spinner — and refetches seasons, episodes and extras.
final show = buildShow();
final season1 = buildSeason(show, 1);
final episode1 = buildEpisode(show, season1, 1);
final episode2 = buildEpisode(show, season1, 2);
final client = _FakeMediaServerClient(
show: show,
childrenByParent: {
show.id: [season1],
season1.id: [episode1, episode2],
},
)..onDeckEpisode = episode1;
final observer = _RecordingNavigatorObserver(popVideoPlayerImmediately: true);
await pumpPhoneDetail(tester, client, show, observer: observer);
expect(find.text('S1E1'), findsOneWidget, reason: 'play button targets the on-deck episode');
expect(find.text('Episode S1E1'), findsOneWidget);
final childrenCallsBeforePlayback = client.childrenPageCalls.length;
observer.pushedRouteNames.clear();
// The next fetchItemWithOnDeck — the post-playback refresh — reports
// the following episode as on deck.
client.onDeckEpisode = episode2;
await tester.tap(
find.descendant(of: find.byType(FocusableActionBar), matching: find.byIcon(Symbols.play_arrow_rounded)),
);
// Pump the push, the immediate pop, and the refresh round-trip one
// frame at a time: the old full-reload path swapped in a loading
// scaffold here and unmounted the episode list.
for (var i = 0; i < 8; i++) {
await tester.pump();
expect(
find.byType(CircularProgressIndicator),
findsNothing,
reason: 'playback return must not raise the full-screen loader',
);
expect(
find.text('Episode S1E1'),
findsOneWidget,
reason: 'loaded episode rows must survive the playback return',
);
}
await tester.pump(const Duration(milliseconds: 300));
expect(observer.pushedRouteNames, contains(kVideoPlayerRouteName));
// Watch state did refresh: the play button now targets the next episode.
expect(find.text('S1E2'), findsOneWidget);
// The lightweight refresh fetches the item + on-deck only — no season
// or episode page refetch, no early-paint (both are full-loader work).
expect(client.childrenPageCalls.length, childrenCallsBeforePlayback);
expect(client.earlyPaints, hasLength(1));
});
testWidgets('shows directors when they are the only additional info', (tester) async {
final movie = testMediaItem(
id: 'director_only',