From e61f0002e547ad9d00c8a89c950dac7e5033e1cd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:54:08 +0200 Subject: [PATCH] test: episode nav, track selection, play queue, more mixins --- test/mixins/context_menu_tap_mixin_test.dart | 97 ++++ test/mixins/library_tab_state_test.dart | 102 +++++ .../episode_navigation_service_test.dart | 132 ++++++ test/services/play_queue_launcher_test.dart | 145 ++++++ test/services/track_manager_test.dart | 338 ++++++++++++++ .../track_selection_service_test.dart | 419 ++++++++++++++++++ 6 files changed, 1233 insertions(+) create mode 100644 test/mixins/context_menu_tap_mixin_test.dart create mode 100644 test/mixins/library_tab_state_test.dart create mode 100644 test/services/episode_navigation_service_test.dart create mode 100644 test/services/play_queue_launcher_test.dart create mode 100644 test/services/track_manager_test.dart create mode 100644 test/services/track_selection_service_test.dart diff --git a/test/mixins/context_menu_tap_mixin_test.dart b/test/mixins/context_menu_tap_mixin_test.dart new file mode 100644 index 00000000..93f41d56 --- /dev/null +++ b/test/mixins/context_menu_tap_mixin_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mixins/context_menu_tap_mixin.dart'; + +// NOTE on coverage scope: +// `ContextMenuTapMixin` is a thin glue layer: +// 1. caches the last-known global tap position so the menu can anchor +// itself at the press location, and +// 2. forwards show calls to the embedded MediaContextMenu's GlobalKey. +// +// The interesting branches for tests are the pure helpers: +// - storeTapPosition writes the global Offset. +// - showContextMenuFromTap / showContextMenu null-safe when no MediaContextMenu +// is attached (currentState is null). +// - isContextMenuOpen returns false when currentState is null. +// +// What's NOT covered (and intentionally skipped): +// - The branch where `contextMenuKey.currentState` is non-null and the menu +// actually opens — that requires mounting the production +// [MediaContextMenu] widget, which depends on a full provider stack +// (PlexClient, MultiServerProvider, etc.). The mixin's job is just to +// forward the call, so the value of widget-level coverage is low. + +class _Probe extends StatefulWidget { + const _Probe({required this.onState}); + + final void Function(_ProbeState state) onState; + + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> with ContextMenuTapMixin<_Probe> { + @override + void initState() { + super.initState(); + widget.onState(this); + } + + @override + Widget build(BuildContext context) => + const Directionality(textDirection: TextDirection.ltr, child: SizedBox.shrink()); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ContextMenuTapMixin', () { + testWidgets('contextMenuKey is a stable GlobalKey instance', (tester) async { + late _ProbeState state; + await tester.pumpWidget(_Probe(onState: (s) => state = s)); + + expect(state.contextMenuKey, isA()); + // GlobalKey identity is stable across rebuilds — important because the + // production widget passes this key to MediaContextMenu and reads + // currentState through it. + final keyA = state.contextMenuKey; + await tester.pump(); + expect(identical(state.contextMenuKey, keyA), isTrue); + }); + + testWidgets('isContextMenuOpen returns false when no menu is mounted', (tester) async { + late _ProbeState state; + await tester.pumpWidget(_Probe(onState: (s) => state = s)); + // currentState is null — the `?? false` fallback must hold. + expect(state.isContextMenuOpen, isFalse); + }); + + testWidgets('storeTapPosition records the global tap offset', (tester) async { + late _ProbeState state; + await tester.pumpWidget(_Probe(onState: (s) => state = s)); + + // Synthesise a TapDownDetails — the mixin only reads globalPosition. + const offset = Offset(123.0, 456.0); + state.storeTapPosition(TapDownDetails(globalPosition: offset, kind: PointerDeviceKind.mouse)); + + // The field is private but both show methods consume it without throwing + // when the MediaContextMenu key has no currentState. Calling them after + // storeTapPosition is the closest observable assertion that the position + // got captured. + expect(state.showContextMenuFromTap, returnsNormally); + }); + + testWidgets('showContextMenuFromTap and showContextMenu are no-ops without a mounted menu', (tester) async { + late _ProbeState state; + await tester.pumpWidget(_Probe(onState: (s) => state = s)); + + // Both helpers go through `currentState?.showContextMenu(...)` so when + // the GlobalKey isn't attached to a MediaContextMenu the calls silently + // succeed. This is the contract: tap handlers can fire even when the + // menu hasn't been instantiated yet. + expect(state.showContextMenu, returnsNormally); + expect(state.showContextMenuFromTap, returnsNormally); + }); + }); +} diff --git a/test/mixins/library_tab_state_test.dart b/test/mixins/library_tab_state_test.dart new file mode 100644 index 00000000..288c24b1 --- /dev/null +++ b/test/mixins/library_tab_state_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mixins/library_tab_state.dart'; +import 'package:plezy/models/plex_library.dart'; +import 'package:provider/provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; + +// NOTE on coverage scope: +// `LibraryTabStateMixin` is a 14-line forwarding mixin: +// - exposes `library` (abstract) and +// - resolves the per-library PlexClient via a BuildContext extension. +// +// Coverage: +// - The mixin returns the same library reference back to subclass code. +// - `getClientForLibrary` throws when there is no MultiServerProvider with a +// matching server — the documented "no client available" failure path. +// +// What's NOT covered (and intentionally skipped): +// - The success path of `getClientForLibrary` requires either a real +// [PlexClient] inside a [MultiServerManager] (which itself requires a +// server registry, network, and prefs) or a deep fake of the manager's +// client cache. Not worth it for a mixin whose only contribution is +// `context.getClientForLibrary(library)`. + +class _Probe extends StatefulWidget { + const _Probe({required this.library, required this.onState}); + + final PlexLibrary library; + final void Function(_ProbeState state, BuildContext context) onState; + + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> with LibraryTabStateMixin<_Probe> { + @override + PlexLibrary get library => widget.library; + + @override + Widget build(BuildContext context) { + // Surface state+context after the first frame so callers can poke the + // mixin against a live BuildContext. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onState(this, context); + }); + return const SizedBox.shrink(); + } +} + +PlexLibrary _lib({String? serverId, String key = '1'}) => + PlexLibrary(key: key, title: 'Movies', type: 'movie', serverId: serverId); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LibraryTabStateMixin', () { + testWidgets('library getter returns the host state\'s library', (tester) async { + late _ProbeState state; + final library = _lib(serverId: 'srv-A', key: 'lib-1'); + + await tester.pumpWidget(_Probe(library: library, onState: (s, _) => state = s)); + await tester.pump(); + + expect(identical(state.library, library), isTrue); + expect(state.library.serverId, 'srv-A'); + expect(state.library.key, 'lib-1'); + }); + + testWidgets('getClientForLibrary throws when no server matches and no fallback online', (tester) async { + late _ProbeState state; + late BuildContext ctx; + + final manager = MultiServerManager(); + final aggregation = DataAggregationService(manager); + final provider = MultiServerProvider(manager, aggregation); + // provider.dispose() cascades to manager.dispose() — only register + // the outer teardown to avoid a double-close on the manager's stream. + addTearDown(provider.dispose); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: _Probe( + library: _lib(serverId: 'srv-missing'), + onState: (s, c) { + state = s; + ctx = c; + }, + ), + ), + ); + await tester.pump(); + + // No registered servers means no client and no fallback — the + // extension throws a localized "no client available" Exception. + expect(() => state.getClientForLibrary(), throwsA(isA())); + expect(ctx.mounted, isTrue); // sanity: exception came from the lookup, not a torn-down context + }); + }); +} diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart new file mode 100644 index 00000000..5cf80a9b --- /dev/null +++ b/test/services/episode_navigation_service_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex_metadata.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/services/episode_navigation_service.dart'; +import 'package:provider/provider.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. + +PlexMetadata _meta(String ratingKey, {String? title}) => + PlexMetadata(ratingKey: ratingKey, title: title ?? 'Episode $ratingKey'); + +class _ProbeWidget extends StatefulWidget { + const _ProbeWidget({required this.metadata, required this.onResult}); + + final PlexMetadata metadata; + final void Function(AdjacentEpisodes) onResult; + + @override + State<_ProbeWidget> createState() => _ProbeWidgetState(); +} + +class _ProbeWidgetState extends State<_ProbeWidget> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + final svc = EpisodeNavigationService(); + final result = await svc.loadAdjacentEpisodes(context: context, metadata: widget.metadata); + widget.onResult(result); + }); + } + + @override + Widget build(BuildContext context) => + const Directionality(textDirection: TextDirection.ltr, child: SizedBox.shrink()); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // =========================================================== + // AdjacentEpisodes data class + // =========================================================== + + group('AdjacentEpisodes', () { + test('default constructor reports no neighbours', () { + final ae = AdjacentEpisodes(); + expect(ae.next, isNull); + expect(ae.previous, isNull); + expect(ae.hasNext, isFalse); + expect(ae.hasPrevious, isFalse); + }); + + test('next/previous flags reflect non-null fields', () { + final ae = AdjacentEpisodes(next: _meta('n'), previous: _meta('p')); + expect(ae.hasNext, isTrue); + expect(ae.hasPrevious, isTrue); + expect(ae.next!.ratingKey, 'n'); + expect(ae.previous!.ratingKey, 'p'); + }); + + test('only-next variant', () { + final ae = AdjacentEpisodes(next: _meta('n')); + expect(ae.hasNext, isTrue); + expect(ae.hasPrevious, isFalse); + }); + + test('only-previous variant', () { + final ae = AdjacentEpisodes(previous: _meta('p')); + expect(ae.hasNext, isFalse); + expect(ae.hasPrevious, isTrue); + }); + }); + + // =========================================================== + // loadAdjacentEpisodes: short-circuit without an active queue + // =========================================================== + + group('loadAdjacentEpisodes', () { + testWidgets('returns empty AdjacentEpisodes when no play queue is active', (tester) async { + // Bare provider — no setPlaybackFromPlayQueue() call → isQueueActive = false. + final playback = PlaybackStateProvider(); + addTearDown(playback.dispose); + + AdjacentEpisodes? result; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: playback, + child: _ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r), + ), + ); + // Drain the post-frame callback and the awaited service call. + await tester.pump(); + await tester.pump(); + + expect(result, isNotNull); + expect(result!.hasNext, isFalse); + expect(result!.hasPrevious, isFalse); + }); + + testWidgets('catches downstream exceptions and returns empty AdjacentEpisodes', (tester) async { + // PlaybackStateProvider not provided → context.read throws. The service + // wraps the entire body in try/catch and returns AdjacentEpisodes() so + // the UI never crashes when the queue subsystem is unavailable. + AdjacentEpisodes? result; + await tester.pumpWidget(_ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r)); + await tester.pump(); + await tester.pump(); + + expect(result, isNotNull); + expect(result!.hasNext, isFalse); + expect(result!.hasPrevious, isFalse); + }); + }); +} diff --git a/test/services/play_queue_launcher_test.dart b/test/services/play_queue_launcher_test.dart new file mode 100644 index 00000000..56f240ac --- /dev/null +++ b/test/services/play_queue_launcher_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex_metadata.dart'; +import 'package:plezy/services/play_queue_launcher.dart'; +import 'package:plezy/services/plex_client.dart'; + +// NOTE on coverage scope: +// `PlayQueueLauncher` is almost entirely network/UI glue: +// - every public method calls into [PlexClient.createPlayQueue] or +// [PlexClient.createShowPlayQueue] (network), +// - then setups [PlaybackStateProvider] (Provider), +// - then calls [navigateToVideoPlayer] (Navigator + DownloadProvider + +// SettingsService singleton + Provider). +// +// Without re-implementing that entire dependency tree, the only meaningful +// unit-testable surface is: +// - The `PlayQueueResult` sealed hierarchy (constructor + identity). +// - `launchShuffledShow` short-circuits BEFORE any network call when the +// metadata is not a show or season — that's a pure pre-flight branch. +// - `launchFromCollectionOrPlaylist` short-circuits when the input is +// neither a `PlexMetadata` nor a `PlexPlaylist`. +// +// Everything else (success/empty-queue/error paths) requires a full +// PlexClient fake + a Provider tree + a real Navigator. Skipped. + +class _StubPlexClient implements PlexClient { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // ============================================================ + // PlayQueueResult sealed hierarchy + // ============================================================ + + group('PlayQueueResult', () { + test('PlayQueueSuccess is a const, identity-comparable singleton', () { + const a = PlayQueueSuccess(); + const b = PlayQueueSuccess(); + expect(identical(a, b), isTrue); + expect(a, isA()); + }); + + test('PlayQueueEmpty is a const, identity-comparable singleton', () { + const a = PlayQueueEmpty(); + const b = PlayQueueEmpty(); + expect(identical(a, b), isTrue); + expect(a, isA()); + }); + + test('PlayQueueError carries the wrapped error', () { + final error = StateError('boom'); + final result = PlayQueueError(error); + expect(result.error, same(error)); + expect(result, isA()); + }); + }); + + // ============================================================ + // Pre-flight branches that don't touch the network + // ============================================================ + + group('launchShuffledShow pre-flight guard', () { + testWidgets('returns PlayQueueError when metadata is not a show or season', (tester) async { + // Build a launcher inside an active Element so its `context.mounted` + // returns true. We don't need a Provider tree because the guard runs + // before any context.read. + late BuildContext capturedContext; + await tester.pumpWidget( + Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ); + + final launcher = PlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); + final result = await launcher.launchShuffledShow( + // 'movie' is not 'show' / 'season'. + metadata: PlexMetadata(ratingKey: 'rk1', type: 'movie'), + showLoadingIndicator: false, + ); + + expect(result, isA()); + final error = (result as PlayQueueError).error; + expect(error.toString(), contains('shows and seasons')); + }); + }); + + group('launchFromCollectionOrPlaylist input guard', () { + testWidgets('returns PlayQueueError for non-collection/playlist input', (tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ); + + final launcher = PlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); + // Passing a String — neither a PlexMetadata nor a PlexPlaylist. + final result = await launcher.launchFromCollectionOrPlaylist(item: 'not-a-real-item', shuffle: false); + + expect(result, isA()); + final error = (result as PlayQueueError).error; + expect(error.toString(), contains('collection or playlist')); + }); + }); + + // ============================================================ + // Constructor + // ============================================================ + + group('constructor', () { + testWidgets('stores all wired arguments', (tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ); + + final client = _StubPlexClient(); + final launcher = PlayQueueLauncher( + context: capturedContext, + client: client, + serverId: 'srv-A', + serverName: 'Plex', + ); + + expect(launcher.context, capturedContext); + expect(identical(launcher.client, client), isTrue); + expect(launcher.serverId, 'srv-A'); + expect(launcher.serverName, 'Plex'); + }); + }); +} diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart new file mode 100644 index 00000000..f181e5e3 --- /dev/null +++ b/test/services/track_manager_test.dart @@ -0,0 +1,338 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex_metadata.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/services/track_manager.dart'; + +import '../test_helpers/prefs.dart'; + +// NOTE on coverage scope: +// `TrackManager` orchestrates the player + Plex client + SettingsService +// singleton. Most paths require a real (or fake) Player surface plus an +// initialized SettingsService. +// +// Coverage: +// - Constructor wiring (mutable fields are settable, default values). +// - `cacheExternalSubtitles` / `lastExternalSubtitles` round-trip. +// - `addExternalSubtitles` invokes the player's addSubtitleTrack for each +// entry with a non-null URI, and silently swallows errors thrown by the +// player (the Future.wait branch wraps each item in try/catch). +// - `cycleSubtitleTrack` / `cycleAudioTrack` are no-ops when the player has +// fewer than 2 real tracks (early-return paths). +// - `onPlaybackRestart` is a no-op when not waiting for external subs. +// - `onSecondarySubtitleTrackChanged` is a documented no-op. +// - `dispose` is idempotent (timers/subscriptions cleared). +// +// What's NOT covered: +// - `applyTrackSelection` / `applyTrackSelectionWhenReady` — depends on +// `SettingsService.getInstance()` returning a service AND `Player.streams` +// emitting Tracks. Out of scope without re-implementing the player. +// - `onAudioTrackChanged` / `onSubtitleTrackChanged` — server-sync paths +// require a fully-faked PlexClient and PlexMediaInfo with realistic +// stream IDs. The matching logic itself lives in [TrackSelectionService] +// and is covered there. +// - `onBackendSwitched` — wraps applyTrackSelectionWhenReady and is +// therefore gated on the same SettingsService dependency. +// - `resumeAfterSubtitleLoad` — schedules a real wall-clock fallback Timer. + +PlexMetadata _meta({String ratingKey = 'rk1'}) => PlexMetadata(ratingKey: ratingKey, type: 'movie'); + +class _FakePlexClient implements PlexClient { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Player that records calls and can be configured per-test. +class _FakePlayer implements Player { + PlayerState _state; + _FakePlayer({Tracks tracks = const Tracks(), TrackSelection track = const TrackSelection()}) + : _state = PlayerState(tracks: tracks, track: track); + + @override + PlayerState get state => _state; + + set tracks(Tracks t) { + _state = _state.copyWith(tracks: t); + } + + // ── Recording surface ──────────────────────────────────────────── + final List<({String uri, String? title, String? language, bool select})> addSubtitleCalls = []; + final List selectedAudio = []; + final List selectedSubtitle = []; + + /// If non-null and >0, fail this many addSubtitleTrack calls before succeeding. + int failAddSubtitleTimes = 0; + + @override + Future addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { + if (failAddSubtitleTimes > 0) { + failAddSubtitleTimes--; + throw StateError('simulated addSubtitleTrack failure'); + } + addSubtitleCalls.add((uri: uri, title: title, language: language, select: select)); + } + + @override + Future selectAudioTrack(AudioTrack t) async => selectedAudio.add(t); + + @override + Future selectSubtitleTrack(SubtitleTrack t) async => selectedSubtitle.add(t); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +TrackManager _make({ + required _FakePlayer player, + PlexMetadata? metadata, + bool active = true, + void Function(String, {Duration? duration})? showMessage, +}) { + return TrackManager( + player: player, + isActive: () => active, + getClient: () => _FakePlexClient(), + getProfileSettings: () => null, + waitForProfileSettings: () async {}, + metadata: metadata ?? _meta(), + showMessage: showMessage, + ); +} + +void main() { + // The constructor doesn't touch prefs, but [dispose] / [applyTrackSelection] + // could leak across tests — reset to be safe. + setUp(resetSharedPreferencesForTest); + + // ============================================================ + // Construction + // ============================================================ + + group('constructor', () { + test('initialises mutable fields with the provided values', () { + final player = _FakePlayer(); + final mgr = TrackManager( + player: player, + isActive: () => true, + getClient: () => _FakePlexClient(), + getProfileSettings: () => null, + waitForProfileSettings: () async {}, + metadata: _meta(), + preferredAudioTrack: const AudioTrack(id: 'a-1', language: 'eng'), + preferredSubtitleTrack: const SubtitleTrack(id: 's-1', language: 'eng'), + preferredSecondarySubtitleTrack: const SubtitleTrack(id: 's-2', language: 'fre'), + ); + addTearDown(mgr.dispose); + + expect(mgr.preferredAudioTrack?.id, 'a-1'); + expect(mgr.preferredSubtitleTrack?.id, 's-1'); + expect(mgr.preferredSecondarySubtitleTrack?.id, 's-2'); + expect(mgr.metadata.ratingKey, 'rk1'); + expect(mgr.waitingForExternalSubsTrackSelection, isFalse); + expect(mgr.lastExternalSubtitles, isEmpty); + expect(mgr.mediaInfo, isNull); + }); + + test('mutable fields can be reassigned (episode-navigation pattern)', () { + final mgr = _make(player: _FakePlayer()); + addTearDown(mgr.dispose); + + mgr.metadata = _meta(ratingKey: 'next'); + mgr.preferredAudioTrack = const AudioTrack(id: 'a2', language: 'fre'); + mgr.waitingForExternalSubsTrackSelection = true; + + expect(mgr.metadata.ratingKey, 'next'); + expect(mgr.preferredAudioTrack?.id, 'a2'); + expect(mgr.waitingForExternalSubsTrackSelection, isTrue); + }); + }); + + // ============================================================ + // External subtitle cache + // ============================================================ + + group('cacheExternalSubtitles', () { + test('round-trips through the lastExternalSubtitles getter', () { + final mgr = _make(player: _FakePlayer()); + addTearDown(mgr.dispose); + + expect(mgr.lastExternalSubtitles, isEmpty); + + final subs = [ + SubtitleTrack.uri('https://example/a.srt', title: 'EN', language: 'eng'), + SubtitleTrack.uri('https://example/b.srt', title: 'FR', language: 'fre'), + ]; + mgr.cacheExternalSubtitles(subs); + expect(mgr.lastExternalSubtitles, subs); + + // Replacing the cache overwrites it (used during episode navigation). + mgr.cacheExternalSubtitles(const []); + expect(mgr.lastExternalSubtitles, isEmpty); + }); + }); + + // ============================================================ + // addExternalSubtitles + // ============================================================ + + group('addExternalSubtitles', () { + test('returns immediately on empty input', () async { + final player = _FakePlayer(); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + await mgr.addExternalSubtitles(const []); + expect(player.addSubtitleCalls, isEmpty); + }); + + test('forwards each subtitle with a URI to the player in parallel', () async { + final player = _FakePlayer(); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + final subs = [ + SubtitleTrack.uri('https://example/a.srt', title: 'EN', language: 'eng'), + SubtitleTrack.uri('https://example/b.srt', title: 'FR', language: 'fre'), + ]; + await mgr.addExternalSubtitles(subs); + + expect(player.addSubtitleCalls, hasLength(2)); + // Order is non-deterministic (Future.wait in parallel) — assert by URI set. + final uris = player.addSubtitleCalls.map((c) => c.uri).toSet(); + expect(uris, {'https://example/a.srt', 'https://example/b.srt'}); + // None should be auto-selected — manager picks afterwards. + expect(player.addSubtitleCalls.every((c) => c.select == false), isTrue); + }); + + test('skips subtitle entries with null URI', () async { + final player = _FakePlayer(); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + // SubtitleTrack default constructor allows uri: null even with + // isExternal: true — exercise the where-filter. + final subs = const [ + SubtitleTrack(id: 'no-uri', isExternal: true), + SubtitleTrack(id: 'with-uri', isExternal: true, uri: 'https://example/c.srt'), + ]; + await mgr.addExternalSubtitles(subs); + + expect(player.addSubtitleCalls, hasLength(1)); + expect(player.addSubtitleCalls.single.uri, 'https://example/c.srt'); + }); + + test('a player error on one entry does not prevent others from succeeding', () async { + final player = _FakePlayer()..failAddSubtitleTimes = 1; + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + final subs = [ + SubtitleTrack.uri('https://example/a.srt', title: 'EN'), + SubtitleTrack.uri('https://example/b.srt', title: 'FR'), + ]; + // Should NOT throw — each per-track future has its own try/catch. + await mgr.addExternalSubtitles(subs); + // One add failed, one succeeded. + expect(player.addSubtitleCalls, hasLength(1)); + }); + }); + + // ============================================================ + // Track cycling early-return paths + // ============================================================ + + group('cycleSubtitleTrack', () { + test('no-op when no real subtitle tracks exist', () { + // Tracks contains only auto/none (filtered out). + final player = _FakePlayer( + tracks: const Tracks(subtitle: [SubtitleTrack(id: 'auto')]), + ); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + mgr.cycleSubtitleTrack(); + expect(player.selectedSubtitle, isEmpty); + }); + + test('no-op when subtitle list is empty', () { + final player = _FakePlayer(); // empty tracks + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + mgr.cycleSubtitleTrack(); + expect(player.selectedSubtitle, isEmpty); + }); + }); + + group('cycleAudioTrack', () { + test('no-op when fewer than 2 real audio tracks exist', () { + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + ), + ); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + mgr.cycleAudioTrack(); + expect(player.selectedAudio, isEmpty); + }); + + test('filters out auto/no when computing the cycle length', () { + // 1 real + 1 auto + 1 no = 1 real → still <2, no cycle. + final player = _FakePlayer( + tracks: const Tracks( + audio: [ + AudioTrack(id: '1', language: 'eng'), + AudioTrack(id: 'auto'), + AudioTrack(id: 'no'), + ], + ), + ); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + mgr.cycleAudioTrack(); + expect(player.selectedAudio, isEmpty); + }); + }); + + // ============================================================ + // Misc handlers + // ============================================================ + + group('onPlaybackRestart', () { + test('no-op when not waiting for external subs', () { + final mgr = _make(player: _FakePlayer()); + addTearDown(mgr.dispose); + + // Don't set the waiting flag — onPlaybackRestart should be a pure no-op + // and must not call applyTrackSelection (which would touch the player). + expect(mgr.waitingForExternalSubsTrackSelection, isFalse); + mgr.onPlaybackRestart(); + // No exception is the contract. + expect(mgr.waitingForExternalSubsTrackSelection, isFalse); + }); + }); + + group('onSecondarySubtitleTrackChanged', () { + test('is a documented no-op', () { + final mgr = _make(player: _FakePlayer()); + addTearDown(mgr.dispose); + // Just verify it returns normally; nothing else to assert. + expect(() => mgr.onSecondarySubtitleTrackChanged(const SubtitleTrack(id: '1')), returnsNormally); + }); + }); + + // ============================================================ + // Lifecycle + // ============================================================ + + group('dispose', () { + test('is idempotent', () { + final mgr = _make(player: _FakePlayer()); + mgr.dispose(); + expect(mgr.dispose, returnsNormally); + }); + }); +} diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart new file mode 100644 index 00000000..f0f76bd0 --- /dev/null +++ b/test/services/track_selection_service_test.dart @@ -0,0 +1,419 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex_media_info.dart'; +import 'package:plezy/models/plex_metadata.dart'; +import 'package:plezy/models/plex_user_profile.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/services/track_selection_service.dart'; + +// NOTE on coverage scope: +// `TrackSelectionService` is a large pure logic surface with one async +// integration point (`selectAndApplyTracks`). We cover: +// +// - `languageMatches` — direct, base-code, and ISO 639 variation matching. +// - `findBestTrackMatch` / `findBestAudioMatch` / `findBestSubtitleMatch` — +// id+title+language exact, title+language, language-only, and the +// "auto"/"no" filtering rule. +// - `findAudioTrackByProfile` — picks the first preferred-language match, +// respects autoSelectAudio, falls back across the language list. +// - `selectAudioTrack` — full priority cascade: +// Priority 1 (preferred from navigation), +// Priority 2 (Plex-selected via media info), +// Priority 3 (per-media metadata.audioLanguage), +// Priority 4 (user profile), +// Priority 5 (default / first track), +// and the empty-list null return. +// - `selectSubtitleTrack` — preferred=off, preferred=tracked, +// Plex-selected, Plex-server-explicit-no-subtitles, default fallback, +// and the off-by-default branch. +// +// Top-level matching helpers (`findMpvTrackForPlexAudio`, +// `findPlexTrackForMpvAudio`, `findMpvTrackForPlexSubtitle`, +// `findPlexTrackForMpvSubtitle`) are exercised indirectly through +// `selectAudioTrack` (Priority 2) and `selectSubtitleTrack` (Priority 2). +// +// What's NOT covered: +// - `selectAndApplyTracks` — depends on a real Player + SettingsService +// singleton + `player.streams.tracks`. Out of scope for a unit test. + +// ============================================================ +// Fixtures +// ============================================================ + +PlexMetadata _meta({String? audioLanguage, String? subtitleLanguage}) => + PlexMetadata(ratingKey: 'rk1', audioLanguage: audioLanguage, subtitleLanguage: subtitleLanguage); + +PlexUserProfile _profile({ + bool autoSelectAudio = true, + String? defaultAudioLanguage, + List? defaultAudioLanguages, + String? defaultSubtitleLanguage, + List? defaultSubtitleLanguages, + int autoSelectSubtitle = 0, +}) { + return PlexUserProfile( + autoSelectAudio: autoSelectAudio, + defaultAudioAccessibility: 0, + defaultAudioLanguage: defaultAudioLanguage, + defaultAudioLanguages: defaultAudioLanguages, + defaultSubtitleLanguage: defaultSubtitleLanguage, + defaultSubtitleLanguages: defaultSubtitleLanguages, + autoSelectSubtitle: autoSelectSubtitle, + defaultSubtitleAccessibility: 0, + defaultSubtitleForced: 1, + watchedIndicator: 1, + mediaReviewsVisibility: 0, + ); +} + +AudioTrack _audio(String id, {String? lang, String? title, String? codec, int? channels, bool isDefault = false}) => + AudioTrack(id: id, language: lang, title: title, codec: codec, channels: channels, isDefault: isDefault); + +SubtitleTrack _sub(String id, {String? lang, String? title, String? codec, bool isDefault = false}) => + SubtitleTrack(id: id, language: lang, title: title, codec: codec, isDefault: isDefault); + +PlexAudioTrack _plexAudio( + int id, { + String? language, + String? languageCode, + String? title, + int? channels, + bool selected = false, + String? codec, +}) { + return PlexAudioTrack( + id: id, + language: language, + languageCode: languageCode ?? language, + title: title, + channels: channels, + selected: selected, + codec: codec, + ); +} + +PlexSubtitleTrack _plexSub( + int id, { + String? language, + String? languageCode, + String? title, + bool selected = false, + bool forced = false, + String? codec, +}) { + return PlexSubtitleTrack( + id: id, + language: language, + languageCode: languageCode ?? language, + title: title, + selected: selected, + forced: forced, + codec: codec, + ); +} + +PlexMediaInfo _info({List? audio, List? subs}) => + PlexMediaInfo(videoUrl: '', audioTracks: audio ?? const [], subtitleTracks: subs ?? const [], chapters: const []); + +/// Minimal Player stub — TrackSelectionService never reads from the player +/// in any of the public-pure helpers we test. +class _StubPlayer implements Player { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +TrackSelectionService _svc({PlexMetadata? metadata, PlexUserProfile? profile, PlexMediaInfo? info}) { + return TrackSelectionService( + player: _StubPlayer(), + metadata: metadata ?? _meta(), + profileSettings: profile, + plexMediaInfo: info, + ); +} + +void main() { + // ============================================================ + // languageMatches + // ============================================================ + + group('languageMatches', () { + final svc = _svc(); + + test('null on either side never matches', () { + expect(svc.languageMatches(null, 'eng'), isFalse); + expect(svc.languageMatches('eng', null), isFalse); + expect(svc.languageMatches(null, null), isFalse); + }); + + test('case-insensitive direct match', () { + expect(svc.languageMatches('ENG', 'eng'), isTrue); + expect(svc.languageMatches('en', 'EN'), isTrue); + }); + + test('strips region suffix on both sides', () { + expect(svc.languageMatches('en-US', 'en'), isTrue); + expect(svc.languageMatches('en', 'en-AU'), isTrue); + expect(svc.languageMatches('en-GB', 'en-US'), isTrue); + }); + + test('matches across ISO 639-1 ↔ 639-2 variations', () { + // "en" ↔ "eng" + expect(svc.languageMatches('en', 'eng'), isTrue); + expect(svc.languageMatches('eng', 'en'), isTrue); + }); + + test('different languages do not match', () { + expect(svc.languageMatches('en', 'fr'), isFalse); + expect(svc.languageMatches('eng', 'fre'), isFalse); + }); + }); + + // ============================================================ + // findBestTrackMatch (via the audio/subtitle wrappers) + // ============================================================ + + group('findBestAudioMatch', () { + final svc = _svc(); + + test('exact id + title + language match wins', () { + final tracks = [_audio('1', lang: 'eng', title: 'Stereo'), _audio('2', lang: 'eng', title: 'Surround')]; + final preferred = _audio('2', lang: 'eng', title: 'Surround'); + expect(svc.findBestAudioMatch(tracks, preferred), tracks[1]); + }); + + test('falls back to title + language when id differs', () { + final tracks = [_audio('1', lang: 'eng', title: 'Stereo'), _audio('2', lang: 'eng', title: 'Surround')]; + // Different id but matching title+language → tracks[1]. + final preferred = _audio('999', lang: 'eng', title: 'Surround'); + expect(svc.findBestAudioMatch(tracks, preferred), tracks[1]); + }); + + test('falls back to language-only match', () { + final tracks = [_audio('1', lang: 'eng', title: 'Stereo')]; + final preferred = _audio('999', lang: 'eng', title: 'Different'); + expect(svc.findBestAudioMatch(tracks, preferred), tracks[0]); + }); + + test('returns null when no language match exists', () { + final tracks = [_audio('1', lang: 'fre')]; + final preferred = _audio('1', lang: 'eng'); + expect(svc.findBestAudioMatch(tracks, preferred), isNull); + }); + + test('filters out auto and no tracks before matching', () { + final tracks = [AudioTrack.auto, AudioTrack.off, _audio('3', lang: 'eng')]; + final preferred = _audio('3', lang: 'eng'); + expect(svc.findBestAudioMatch(tracks, preferred), tracks[2]); + }); + + test('returns null on an empty list', () { + expect(svc.findBestAudioMatch(const [], _audio('1', lang: 'eng')), isNull); + }); + + test('returns null when only auto/no tracks remain after filtering', () { + expect(svc.findBestAudioMatch([AudioTrack.auto, AudioTrack.off], _audio('1', lang: 'eng')), isNull); + }); + }); + + group('findBestSubtitleMatch', () { + final svc = _svc(); + + test('preferred id="no" returns SubtitleTrack.off', () { + // Even with non-empty available tracks, "no" preference always means off. + final result = svc.findBestSubtitleMatch([_sub('1', lang: 'eng')], const SubtitleTrack(id: 'no')); + expect(identical(result, SubtitleTrack.off), isTrue); + }); + + test('matches by language when title differs', () { + final tracks = [_sub('1', lang: 'eng', title: 'English')]; + expect(svc.findBestSubtitleMatch(tracks, _sub('999', lang: 'eng', title: 'Other')), tracks[0]); + }); + + test('returns null on no match', () { + expect(svc.findBestSubtitleMatch([_sub('1', lang: 'fre')], _sub('1', lang: 'eng')), isNull); + }); + }); + + // ============================================================ + // findAudioTrackByProfile + // ============================================================ + + group('findAudioTrackByProfile', () { + final svc = _svc(); + + test('returns null when autoSelectAudio is false', () { + final profile = _profile(autoSelectAudio: false, defaultAudioLanguage: 'eng'); + expect(svc.findAudioTrackByProfile([_audio('1', lang: 'eng')], profile), isNull); + }); + + test('returns null when no preferred languages are configured', () { + final profile = _profile(); // autoSelect=true, but no languages. + expect(svc.findAudioTrackByProfile([_audio('1', lang: 'eng')], profile), isNull); + }); + + test('matches the primary defaultAudioLanguage first', () { + final tracks = [_audio('1', lang: 'fre'), _audio('2', lang: 'eng')]; + final profile = _profile(defaultAudioLanguage: 'eng', defaultAudioLanguages: const ['fre']); + expect(svc.findAudioTrackByProfile(tracks, profile), tracks[1]); + }); + + test('falls back to next language in list when primary is missing', () { + final tracks = [_audio('1', lang: 'spa')]; + final profile = _profile(defaultAudioLanguage: 'eng', defaultAudioLanguages: const ['spa']); + expect(svc.findAudioTrackByProfile(tracks, profile), tracks[0]); + }); + + test('returns null when none of the preferred languages match', () { + final tracks = [_audio('1', lang: 'jpn')]; + final profile = _profile(defaultAudioLanguage: 'eng', defaultAudioLanguages: const ['fre']); + expect(svc.findAudioTrackByProfile(tracks, profile), isNull); + }); + + test('returns null on empty available tracks', () { + final profile = _profile(defaultAudioLanguage: 'eng'); + expect(svc.findAudioTrackByProfile(const [], profile), isNull); + }); + }); + + // ============================================================ + // selectAudioTrack — the priority cascade + // ============================================================ + + group('selectAudioTrack', () { + test('returns null on empty available tracks', () { + expect(_svc().selectAudioTrack(const [], _audio('1', lang: 'eng')), isNull); + }); + + test('Priority 1: preferred-from-navigation wins when matching', () { + final tracks = [_audio('1', lang: 'fre'), _audio('2', lang: 'eng')]; + final result = _svc().selectAudioTrack(tracks, _audio('2', lang: 'eng')); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.navigation); + expect(result.track, tracks[1]); + }); + + test('Priority 2: Plex-selected track from media info', () { + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; + final info = _info( + audio: [ + _plexAudio(1, language: 'eng', languageCode: 'eng', selected: false), + _plexAudio(2, language: 'fre', languageCode: 'fre', selected: true), // selected by Plex + ], + ); + // No preferred → Priority 1 misses; per-media + profile not provided → + // matcher resolves on Plex's selected (French). + final result = _svc(info: info).selectAudioTrack(tracks, null); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.plexSelected); + expect(result.track.language, 'fre'); + }); + + test('Priority 3: per-media audioLanguage from metadata', () { + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; + final result = _svc(metadata: _meta(audioLanguage: 'fre')).selectAudioTrack(tracks, null); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.perMedia); + expect(result.track.language, 'fre'); + }); + + test('Priority 4: user profile when nothing higher matches', () { + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; + final profile = _profile(defaultAudioLanguage: 'eng'); + final result = _svc(profile: profile).selectAudioTrack(tracks, null); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.profile); + expect(result.track.language, 'eng'); + }); + + test('Priority 5: default-flagged track as last resort', () { + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre', isDefault: true)]; + final result = _svc().selectAudioTrack(tracks, null); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.defaultTrack); + expect(result.track.id, 'B'); + }); + + test('Priority 5: first track when none flagged default', () { + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; + final result = _svc().selectAudioTrack(tracks, null); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.defaultTrack); + expect(result.track.id, 'A'); + }); + + test('preferred mismatch falls through to lower priority', () { + // preferred has a language that is NOT in the available tracks — Priority 1 + // misses; Priority 5 picks the first track. + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; + final result = _svc().selectAudioTrack(tracks, _audio('Z', lang: 'jpn')); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.defaultTrack); + }); + }); + + // ============================================================ + // selectSubtitleTrack + // ============================================================ + + group('selectSubtitleTrack', () { + test('Priority 1: preferred id="no" forces subtitles off', () { + final tracks = [_sub('1', lang: 'eng', isDefault: true)]; + final result = _svc().selectSubtitleTrack(tracks, const SubtitleTrack(id: 'no'), null); + expect(result.priority, TrackSelectionPriority.navigation); + expect(result.track.id, 'no'); + }); + + test('Priority 1: preferred subtitle from navigation matches by language', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; + final result = _svc().selectSubtitleTrack(tracks, _sub('99', lang: 'fre'), null); + expect(result.priority, TrackSelectionPriority.navigation); + expect(result.track.id, '2'); + }); + + test('Priority 2: Plex server-selected subtitle wins', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; + final info = _info( + subs: [ + _plexSub(10, language: 'eng', languageCode: 'eng'), + _plexSub(11, language: 'fre', languageCode: 'fre', selected: true), + ], + ); + final result = _svc(info: info).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.plexSelected); + expect(result.track.language, 'fre'); + }); + + test('Priority 2: Plex media info has subs but none selected → off', () { + // Server's explicit decision: there ARE subs but the user opted out. + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; + final info = _info( + subs: [ + _plexSub(10, language: 'eng'), + _plexSub(11, language: 'fre'), + ], + ); + final result = _svc(info: info).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.plexSelected); + expect(result.track.id, 'no'); + }); + + test('Priority 3: default-flagged track when no Plex info', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre', isDefault: true)]; + final result = _svc().selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.defaultTrack); + expect(result.track.id, '2'); + }); + + test('Priority 4: off when no default and no info', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; + final result = _svc().selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.off); + expect(result.track.id, 'no'); + }); + + test('Priority 4: off when no available tracks at all', () { + final result = _svc().selectSubtitleTrack(const [], null, null); + expect(result.priority, TrackSelectionPriority.off); + expect(result.track.id, 'no'); + }); + }); +}