Files
plezy/test/screens/main_screen_layout_test.dart
T
edde746 0f5cd11c08 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.
2026-08-21 19:23:41 +02:00

470 lines
18 KiB
Dart

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;
const reservedWidth = SideNavigationRailState.tvCollapsedWidth;
final collapsed = mainScreenSideNavigationContentLayout(
viewportWidth: viewportWidth,
currentSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
reservedSideNavigationWidth: reservedWidth,
);
final expanded = mainScreenSideNavigationContentLayout(
viewportWidth: viewportWidth,
currentSideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: reservedWidth,
);
expect(collapsed.width, viewportWidth - SideNavigationRailState.tvCollapsedWidth);
expect(expanded.width, collapsed.width);
expect(collapsed.left, SideNavigationRailState.tvCollapsedWidth);
expect(expanded.left, SideNavigationRailState.expandedWidth);
expect(collapsed.left + collapsed.width, viewportWidth);
expect(expanded.left + expanded.width, viewportWidth + SideNavigationRailState.expandedWidth - reservedWidth);
});
test('side navigation reserves expanded width when always open', () {
const viewportWidth = 1280.0;
final expanded = mainScreenSideNavigationContentLayout(
viewportWidth: viewportWidth,
currentSideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.expandedWidth,
);
expect(expanded.left, SideNavigationRailState.expandedWidth);
expect(expanded.width, viewportWidth - SideNavigationRailState.expandedWidth);
expect(expanded.left + expanded.width, viewportWidth);
});
test('tvOS Menu pass-through only enables at root with sidebar focus', () {
bool shouldPass({
bool isAppleTV = true,
bool isShowingProfileSelection = false,
bool isOverlaySheetOpen = false,
bool isRouteCurrent = true,
bool isSidebarFocused = true,
bool hasVisibleTabs = true,
bool isCurrentTabRoot = true,
}) {
return shouldPassTvosMenuToSystem(
isAppleTV: isAppleTV,
isShowingProfileSelection: isShowingProfileSelection,
isOverlaySheetOpen: isOverlaySheetOpen,
isRouteCurrent: isRouteCurrent,
isSidebarFocused: isSidebarFocused,
hasVisibleTabs: hasVisibleTabs,
isCurrentTabRoot: isCurrentTabRoot,
);
}
expect(shouldPass(), isTrue);
expect(shouldPass(isSidebarFocused: false), isFalse);
expect(shouldPass(isCurrentTabRoot: false), isFalse);
expect(shouldPass(isOverlaySheetOpen: true), isFalse);
expect(shouldPass(isRouteCurrent: false), isFalse);
expect(shouldPass(isAppleTV: false), isFalse);
expect(shouldPass(isShowingProfileSelection: true), isFalse);
expect(shouldPass(hasVisibleTabs: false), isFalse);
});
test('desktop physical Escape is reserved for window fullscreen only at root Home', () {
bool shouldHandle({
bool isDesktop = true,
bool isPhysicalKeyboardEvent = true,
LogicalKeyboardKey logicalKey = LogicalKeyboardKey.escape,
bool isCurrentRoute = true,
bool isHomeTab = true,
}) {
return shouldHandleDesktopRootEscape(
isDesktop: isDesktop,
isPhysicalKeyboardEvent: isPhysicalKeyboardEvent,
logicalKey: logicalKey,
isCurrentRoute: isCurrentRoute,
isHomeTab: isHomeTab,
);
}
expect(shouldHandle(), isTrue);
expect(shouldHandle(isHomeTab: false), isFalse);
expect(shouldHandle(isCurrentRoute: false), isFalse);
// A remote/gamepad-synthesized escape is not a physical keyboard Escape;
// it keeps the press-back-again exit path.
expect(shouldHandle(isPhysicalKeyboardEvent: false), isFalse);
expect(shouldHandle(isDesktop: false), isFalse);
expect(shouldHandle(logicalKey: LogicalKeyboardKey.gameButtonB), isFalse);
});
test('profile switch invalidates nothing here — the keyed session remount owns it', () {
expect(
profileInvalidationAction(
previousProfileId: 'owner',
currentProfileId: 'kids',
wasBindingPreviously: false,
isBindingNow: false,
),
ProfileInvalidationAction.none,
);
});
test('same-profile rebind invalidates once when binding settles', () {
expect(
profileInvalidationAction(
previousProfileId: 'owner',
currentProfileId: 'owner',
wasBindingPreviously: true,
isBindingNow: false,
),
ProfileInvalidationAction.invalidateNow,
);
expect(
profileInvalidationAction(
previousProfileId: 'owner',
currentProfileId: 'owner',
wasBindingPreviously: false,
isBindingNow: false,
),
ProfileInvalidationAction.none,
);
});
test('resume prompt is suppressed during active video playback (#2034)', () {
bool should({
bool resumedFromBackground = true,
bool isOffline = false,
bool alreadyShowingProfileSelection = false,
bool isMobilePlatform = true,
bool hasActiveVideoPlayback = false,
}) {
return shouldShowProfileSelectionOnResume(
resumedFromBackground: resumedFromBackground,
isOffline: isOffline,
alreadyShowingProfileSelection: alreadyShowingProfileSelection,
isMobilePlatform: isMobilePlatform,
hasActiveVideoPlayback: hasActiveVideoPlayback,
);
}
expect(should(), isTrue);
// Waking the device mid-stream resumes the stream; the picker would
// fight the player's focus self-heal for the remote.
expect(should(hasActiveVideoPlayback: true), isFalse);
expect(should(resumedFromBackground: false), isFalse);
expect(should(isOffline: true), isFalse);
expect(should(alreadyShowingProfileSelection: true), isFalse);
// Desktop "resumed" fires on every window focus gain; startup prompt only.
expect(should(isMobilePlatform: false), isFalse);
});
group('ProfileSelectionResumeGate', () {
test('does not prompt for overlay-style focus loss and regain (#1990)', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse);
});
test('prompts exactly once after a genuine backgrounding', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.paused), isFalse);
expect(gate.wasBackgrounded, isTrue);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse);
expect(gate.wasBackgrounded, isFalse);
});
test('prompts after an iOS-style hidden -> inactive -> resumed return', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.hidden), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue);
});
test('latches every backgrounding state', () {
for (final state in [AppLifecycleState.hidden, AppLifecycleState.paused, AppLifecycleState.detached]) {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(state), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue, reason: '$state');
}
});
test('does not prompt without a prior backgrounding (cold open)', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse);
});
});
group('ContentRefreshResumeGate', () {
// Injectable clock: tests advance `now` instead of sleeping.
ContentRefreshResumeGate gateAt(DateTime Function() now) =>
ContentRefreshResumeGate(staleAfter: const Duration(minutes: 5), now: now);
test('refreshes once after a backgrounding longer than the threshold (#2043)', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(hours: 18));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isTrue);
// Consumed: an immediate second resume must not refresh again.
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('does not refresh after a short backgrounding', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.hidden), isFalse);
now = now.add(const Duration(minutes: 4, seconds: 59));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('does not refresh for overlay-style inactive -> resumed', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.inactive), isFalse);
now = now.add(const Duration(hours: 1));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('does not refresh on a cold-open resume with no prior backgrounding', () {
final gate = gateAt(DateTime.now);
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
});
test('keeps the earliest backgrounded time across hidden -> paused churn', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.hidden), isFalse);
now = now.add(const Duration(minutes: 4));
// A later deeper state must not restart the clock.
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(minutes: 2));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isTrue);
});
test('resume resets the latch for the next backgrounding', () {
var now = DateTime(2026, 8, 20, 12);
final gate = gateAt(() => now);
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(minutes: 1));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isFalse);
// The stale clock must start from the *new* backgrounding, not the old one.
expect(gate.consumeRefreshOn(AppLifecycleState.paused), isFalse);
now = now.add(const Duration(minutes: 5));
expect(gate.consumeRefreshOn(AppLifecycleState.resumed), isTrue);
});
});
testWidgets('side navigation bleed animates from the previous value', (tester) async {
Widget build(double targetBleed) {
return Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: [
SideNavigationBleedBuilder(
targetBleed: targetBleed,
builder: (context, bleed, _) => Positioned(
key: const ValueKey('bleed-position'),
top: 0,
left: -bleed,
width: 1280,
height: 10,
child: const SizedBox.shrink(),
),
),
],
),
);
}
double left() => tester.widget<Positioned>(find.byKey(const ValueKey('bleed-position'))).left!;
await tester.pumpWidget(build(SideNavigationRailState.tvCollapsedWidth));
expect(left(), -SideNavigationRailState.tvCollapsedWidth);
await tester.pumpWidget(build(SideNavigationRailState.expandedWidth));
expect(left(), closeTo(-SideNavigationRailState.tvCollapsedWidth, 0.001));
await tester.pump(const Duration(milliseconds: 100));
expect(left(), lessThan(-SideNavigationRailState.tvCollapsedWidth));
expect(left(), greaterThan(-SideNavigationRailState.expandedWidth));
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();
}