Files
plezy/test/widgets/music/mini_player_test.dart
T
edde746 e7f97cc090 refactor(music): move the playback stub beside the tests that use it
StubMusicPlaybackService is a base for test doubles with no production
caller, so `check-unused-code lib` flagged it and the analysis job
failed. Moves it to test/test_helpers/, where shared fakes belong.
2026-07-26 23:05:53 +02:00

416 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/music_navigation.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/music/mini_player.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/profile_stack.dart';
import '../../test_helpers/stub_music_playback_service.dart';
final _track = testMediaItem(
id: 'track_1',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Dawn',
parentId: 'album_1',
parentTitle: 'First Light',
grandparentId: 'artist_1',
grandparentTitle: 'Test Artist',
durationMs: 180000,
serverId: 'server_1',
serverName: 'Server',
);
class _RecordingNavigatorObserver extends NavigatorObserver {
final List<Route<dynamic>> pushedRoutes = [];
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
pushedRoutes.add(route);
}
}
class _FakeDownloadProvider extends ChangeNotifier implements DownloadProvider {
@override
DownloadProgress? getProgress(String globalKey) => null;
@override
bool hasSyncRule(String globalKey) => false;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Fixed-state fake: reports a playing session with a single-track queue.
class _FakeMusicService extends StubMusicPlaybackService {
MediaItem? track;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast(sync: true);
Duration _position = Duration.zero;
int previousCalls = 0;
int toggleCalls = 0;
int nextCalls = 0;
int stopCalls = 0;
_FakeMusicService({this.track});
@override
MediaItem? get currentTrack => track;
@override
MusicPlaybackStatus get status => track == null ? MusicPlaybackStatus.idle : MusicPlaybackStatus.playing;
@override
Duration get position => _position;
@override
Stream<Duration> get positionStream => _positionController.stream;
@override
Duration? get duration => track == null ? null : const Duration(minutes: 3);
@override
List<MediaItem> get queue => track == null ? const [] : [track!];
@override
int get currentIndex => track == null ? -1 : 0;
void emitPosition(Duration position) {
_position = position;
_positionController.add(position);
}
void advanceTo(MediaItem next) {
track = next;
_position = Duration.zero;
notifyListeners();
}
@override
Future<void> previous() async {
previousCalls++;
}
@override
Future<void> togglePlayPause() async {
toggleCalls++;
}
@override
Future<void> next() async {
nextCalls++;
}
@override
Future<void> stop() async {
stopCalls++;
}
@override
void dispose() {
_positionController.close();
super.dispose();
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
await SettingsService.getInstance();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
Widget wrap({
required MusicPlaybackService service,
required MusicUiRouteObserver observer,
TargetPlatform platform = TargetPlatform.android,
NavigatorObserver? navigatorObserver,
ActiveProfileProvider? activeProfileProvider,
DownloadProvider? downloadProvider,
}) {
addTearDown(service.dispose);
addTearDown(observer.suppress.dispose);
final multiServerProvider = testMultiServer().provider;
return TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<MusicPlaybackService>.value(value: service),
ChangeNotifierProvider<MiniPlayerInsetController>(create: (_) => MiniPlayerInsetController()),
Provider<MusicUiRouteObserver>.value(value: observer),
if (activeProfileProvider != null)
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
if (downloadProvider != null) ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: platform),
navigatorObservers: [?navigatorObserver],
home: const Stack(
children: [
SizedBox.expand(),
Positioned.fill(child: MusicMiniPlayerOverlay()),
],
),
),
),
);
}
testWidgets('appears when the service has a current track', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
expect(find.text('Test Artist'), findsOneWidget);
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout)
});
testWidgets('details control announces the current title and artist exactly once', (tester) async {
final semantics = tester.ensureSemantics();
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
var finder = find.bySemanticsLabel('Dawn');
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, 'Test Artist');
expect(find.bySemanticsLabel('Test Artist'), findsNothing);
service.advanceTo(
testMediaItem(
id: 'track_2',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Noon',
grandparentId: 'artist_2',
grandparentTitle: 'Second Artist',
durationMs: 180000,
serverId: 'server_1',
),
);
await tester.pumpAndSettle();
finder = find.bySemanticsLabel('Noon');
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, 'Second Artist');
expect(find.bySemanticsLabel('Second Artist'), findsNothing);
semantics.dispose();
});
testWidgets('progress resets immediately when the current track changes', (tester) async {
final nextTrack = testMediaItem(
id: 'track_2',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Noon',
durationMs: 180000,
serverId: 'server_1',
);
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
service.emitPosition(const Duration(minutes: 2));
await tester.pump();
Iterable<double?> progressWidths() => tester
.widgetList<FractionallySizedBox>(find.byType(FractionallySizedBox))
.where((box) => box.heightFactor == 1)
.map((box) => box.widthFactor);
expect(progressWidths(), contains(closeTo(2 / 3, 0.001)));
service.advanceTo(nextTrack);
await tester.pump();
expect(progressWidths(), contains(0.0));
expect(progressWidths(), isNot(contains(closeTo(2 / 3, 0.001))));
});
testWidgets('tapping the card edge opens the named Now Playing route', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
final navigatorObserver = _RecordingNavigatorObserver();
await tester.pumpWidget(wrap(service: service, observer: observer, navigatorObserver: navigatorObserver));
await tester.pumpAndSettle();
final cardRect = tester.getRect(find.byKey(const ValueKey('mini_player_dismiss')));
await tester.tapAt(Offset(cardRect.left + 2, cardRect.center.dy));
final route = navigatorObserver.pushedRoutes.last;
expect(route.settings.name, kNowPlayingRouteName);
expect(service.previousCalls, 0);
expect(service.toggleCalls, 0);
expect(service.nextCalls, 0);
expect(service.stopCalls, 0);
route.navigator!.removeRoute(route);
});
testWidgets('desktop transport buttons own their actions without opening Now Playing', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
final navigatorObserver = _RecordingNavigatorObserver();
await tester.pumpWidget(
wrap(service: service, observer: observer, platform: TargetPlatform.macOS, navigatorObserver: navigatorObserver),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(t.music.previousTrack));
await tester.tap(find.byTooltip(t.common.pause));
await tester.tap(find.byTooltip(t.music.nextTrack));
await tester.tap(find.byTooltip(t.music.stopPlayback));
expect(service.previousCalls, 1);
expect(service.toggleCalls, 1);
expect(service.nextCalls, 1);
expect(service.stopCalls, 1);
expect(navigatorObserver.pushedRoutes.where((route) => route.settings.name == kNowPlayingRouteName), isEmpty);
});
testWidgets('desktop close icon alone is 20px while transport icons remain 24px', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer, platform: TargetPlatform.macOS));
await tester.pumpAndSettle();
final icons = tester
.widgetList<AppIcon>(find.descendant(of: find.byType(FocusableActionBar), matching: find.byType(AppIcon)))
.toList();
expect(icons, hasLength(4));
expect(icons.singleWhere((icon) => icon.icon == Symbols.close_rounded).size, 20);
expect(icons.where((icon) => icon.icon != Symbols.close_rounded).map((icon) => icon.size), everyElement(24));
});
testWidgets('keyboard long-press anchors the context menu to the focused card instead of a stale pointer', (
tester,
) async {
final stack = await ProfileStack.create(withStorage: false);
final downloadProvider = _FakeDownloadProvider();
addTearDown(() async {
await stack.dispose();
downloadProvider.dispose();
});
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(
wrap(
service: service,
observer: observer,
activeProfileProvider: stack.active,
downloadProvider: downloadProvider,
),
);
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('mini_player_dismiss'));
final cardRect = tester.getRect(card);
final gesture = await tester.startGesture(Offset(cardRect.left + 4, cardRect.center.dy));
await tester.pump(const Duration(milliseconds: 150));
await gesture.cancel();
await tester.pump();
tester.widget<FocusableWrapper>(find.byType(FocusableWrapper)).focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 550));
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
final playMenuIcon = find.byWidgetPredicate(
(widget) => widget is AppIcon && widget.icon == Symbols.play_arrow_rounded,
);
expect(playMenuIcon, findsOneWidget);
final menuSurface = find.ancestor(of: playMenuIcon, matching: find.byType(BottomSheet));
expect(menuSurface, findsOneWidget);
expect(tester.getCenter(menuSurface).dx, closeTo(cardRect.center.dx, 0.1));
await tester.tapAt(const Offset(4, 4));
await tester.pumpAndSettle();
});
testWidgets('stays hidden while a named suppressing route is active', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
final route = MaterialPageRoute<void>(
settings: const RouteSettings(name: kNowPlayingRouteName),
builder: (_) => const SizedBox.shrink(),
);
observer.didPush(route, null);
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
observer.didRemove(route, null);
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
});
testWidgets('renders nothing without a current track', (tester) async {
final service = _FakeMusicService();
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
expect(find.byType(IconButton), findsNothing);
});
testWidgets('never renders on TV', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
expect(find.byType(IconButton), findsNothing);
});
}