test: episode nav, track selection, play queue, more mixins
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user