test: episode nav, track selection, play queue, more mixins

This commit is contained in:
edde746
2026-04-25 13:54:08 +02:00
parent 303bd406c2
commit e61f0002e5
6 changed files with 1233 additions and 0 deletions
@@ -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>());
// 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);
});
});
}
+102
View File
@@ -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<MultiServerProvider>.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<Exception>()));
expect(ctx.mounted, isTrue); // sanity: exception came from the lookup, not a torn-down context
});
});
}
@@ -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<PlaybackStateProvider>.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);
});
});
}
+145
View File
@@ -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<PlayQueueResult>());
});
test('PlayQueueEmpty is a const, identity-comparable singleton', () {
const a = PlayQueueEmpty();
const b = PlayQueueEmpty();
expect(identical(a, b), isTrue);
expect(a, isA<PlayQueueResult>());
});
test('PlayQueueError carries the wrapped error', () {
final error = StateError('boom');
final result = PlayQueueError(error);
expect(result.error, same(error));
expect(result, isA<PlayQueueResult>());
});
});
// ============================================================
// 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<PlayQueueError>());
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<PlayQueueError>());
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');
});
});
}
+338
View File
@@ -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<AudioTrack> selectedAudio = [];
final List<SubtitleTrack> selectedSubtitle = [];
/// If non-null and >0, fail this many addSubtitleTrack calls before succeeding.
int failAddSubtitleTimes = 0;
@override
Future<void> 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<void> selectAudioTrack(AudioTrack t) async => selectedAudio.add(t);
@override
Future<void> 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);
});
});
}
@@ -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<String>? defaultAudioLanguages,
String? defaultSubtitleLanguage,
List<String>? 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<PlexAudioTrack>? audio, List<PlexSubtitleTrack>? 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');
});
});
}