fix(app): restore playback and state lifecycle contracts

This commit is contained in:
edde746
2026-07-25 16:16:04 +02:00
parent 73f4a3e055
commit 4af77f4696
58 changed files with 2448 additions and 363 deletions
+25 -1
View File
@@ -22,6 +22,19 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import '../test_helpers/prefs.dart';
final class _EnsureOpenTrackingInterceptor extends QueryInterceptor {
var calls = 0;
var completed = false;
@override
Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
calls++;
final result = await executor.ensureOpen(user);
completed = true;
return result;
}
}
void main() {
final suite = _AppDatabaseTestSuite();
suite.registerTests();
@@ -731,7 +744,18 @@ class _AppDatabaseTestSuite {
await seeded.close();
seeded = null;
reopened = AppDatabase.forTesting(NativeDatabase(file));
resetSharedPreferencesForTest();
final prefs = await BaseSharedPreferencesService.sharedCache();
final openTracker = _EnsureOpenTrackingInterceptor();
final bootstrap = await AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) => NativeDatabase(databaseFile).interceptWith(openTracker),
);
reopened = bootstrap.database;
expect(openTracker.calls, greaterThanOrEqualTo(1));
expect(openTracker.completed, isTrue);
final owners = await reopened.select(reopened.downloadOwners).get();
DownloadOwnerItem owner(String profileId, String globalKey) =>
owners.singleWhere((row) => row.profileId == profileId && row.globalKey == globalKey);
+100 -83
View File
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as path;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/models/download_models.dart';
@@ -130,45 +131,50 @@ void main() {
group('insertQueuedDownload', () {
test('atomically persists media identity, scope, policy, and queue state', () async {
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_');
final databaseFile = File('${tempDir.path}/downloads.sqlite');
addTearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final outcome = await db.insertQueuedDownload(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: 'episode-1',
globalKey: 'srv:episode-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
mediaIndex: 3,
mediaSourceId: 'source-3',
priority: 7,
downloadSubtitles: false,
downloadArtwork: true,
);
expect(outcome, QueueDownloadOutcome.admitted);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
try {
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final outcome = await db.insertQueuedDownload(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: 'episode-1',
globalKey: 'srv:episode-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
mediaIndex: 3,
mediaSourceId: 'source-3',
priority: 7,
downloadSubtitles: false,
downloadArtwork: true,
);
expect(outcome, QueueDownloadOutcome.admitted);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final media = (await db.select(db.downloadedMedia).get()).single;
final queued = (await db.select(db.downloadQueue).get()).single;
expect(media.serverId, 'srv');
expect(media.clientScopeId, 'srv/user-a');
expect(media.ratingKey, 'episode-1');
expect(media.parentRatingKey, 'season-1');
expect(media.grandparentRatingKey, 'show-1');
expect(media.status, DownloadStatus.queued.index);
expect(media.mediaIndex, 3);
expect(media.mediaSourceId, 'source-3');
expect(queued.mediaGlobalKey, media.globalKey);
expect(queued.priority, 7);
expect(queued.downloadSubtitles, isFalse);
expect(queued.downloadArtwork, isTrue);
final media = (await db.select(db.downloadedMedia).get()).single;
final queued = (await db.select(db.downloadQueue).get()).single;
expect(media.serverId, 'srv');
expect(media.clientScopeId, 'srv/user-a');
expect(media.ratingKey, 'episode-1');
expect(media.parentRatingKey, 'season-1');
expect(media.grandparentRatingKey, 'show-1');
expect(media.status, DownloadStatus.queued.index);
expect(media.mediaIndex, 3);
expect(media.mediaSourceId, 'source-3');
expect(queued.mediaGlobalKey, media.globalKey);
expect(queued.priority, 7);
expect(queued.downloadSubtitles, isFalse);
expect(queued.downloadArtwork, isTrue);
} finally {
await db.close();
db = AppDatabase.forTesting(NativeDatabase.memory());
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
}
});
test('requeues retryable rows without replacing identity or physical fields', () async {
@@ -369,57 +375,68 @@ void main() {
});
test('rolls back both new and replacement media rows when queue insertion fails', () async {
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_');
final databaseFile = File('${tempDir.path}/downloads.sqlite');
addTearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'existing',
globalKey: 'srv:existing',
type: 'movie',
status: DownloadStatus.failed.index,
);
await db.updateDownloadProgress('srv:existing', 41, 410, 1000);
await db.customStatement('''
CREATE TRIGGER reject_download_queue_insert
BEFORE INSERT ON download_queue
BEGIN
SELECT RAISE(ABORT, 'queue insert rejected');
END
''');
await expectLater(
db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'),
throwsA(anything),
);
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty);
await expectLater(
db.insertQueuedDownload(
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_');
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
try {
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'existing',
globalKey: 'srv:existing',
type: 'movie',
),
throwsA(anything),
);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty);
final preserved = await db.getDownloadedMedia('srv:existing');
expect(preserved?.status, DownloadStatus.failed.index);
expect(preserved?.progress, 41);
expect(preserved?.downloadedBytes, 410);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(),
isEmpty,
);
status: DownloadStatus.failed.index,
);
await db.updateDownloadProgress('srv:existing', 41, 410, 1000);
await db.customStatement('''
CREATE TRIGGER reject_download_queue_insert
BEFORE INSERT ON download_queue
BEGIN
SELECT RAISE(ABORT, 'queue insert rejected');
END
''');
await expectLater(
db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'),
throwsA(anything),
);
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(),
isEmpty,
);
await expectLater(
db.insertQueuedDownload(
serverId: ServerId('srv'),
ratingKey: 'existing',
globalKey: 'srv:existing',
type: 'movie',
),
throwsA(anything),
);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(),
isEmpty,
);
final preserved = await db.getDownloadedMedia('srv:existing');
expect(preserved?.status, DownloadStatus.failed.index);
expect(preserved?.progress, 41);
expect(preserved?.downloadedBytes, 410);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(),
isEmpty,
);
} finally {
await db.close();
db = AppDatabase.forTesting(NativeDatabase.memory());
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
}
});
});
+91
View File
@@ -1,4 +1,7 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/card_focus_scope.dart';
@@ -121,4 +124,92 @@ void main() {
semanticsOwner.removeListener(countSemanticsUpdate);
semantics.dispose();
});
testWidgets('semantic label replaces child semantics by default', (tester) async {
final semantics = tester.ensureSemantics();
var activations = 0;
var childActivations = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableWrapper(
semanticLabel: 'Open details',
semanticValue: 'Ready',
onSelect: () => activations++,
child: Semantics(
label: 'Decorative artwork',
value: 'Decorative state',
button: true,
onTap: () => childActivations++,
child: const Text('Poster'),
),
),
),
),
);
final finder = find.bySemanticsLabel('Open details');
expect(finder, findsOneWidget);
expect(find.bySemanticsLabel('Decorative artwork'), findsNothing);
expect(find.bySemanticsLabel('Poster'), findsNothing);
final node = tester.getSemantics(finder);
final data = node.getSemanticsData();
expect(data.value, 'Ready');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
node.owner!.performAction(node.id, SemanticsAction.tap);
expect(activations, 1);
expect(childActivations, 0);
semantics.dispose();
});
testWidgets('merge mode supplements non-interactive child semantics', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableWrapper(
semanticLabel: 'Expand',
excludeChildSemantics: false,
onSelect: () {},
child: Semantics(value: 'Available offline', child: const Text('Visible synopsis')),
),
),
),
);
final finder = find.bySemanticsLabel(RegExp('Expand'));
expect(finder, findsOneWidget);
expect(find.bySemanticsLabel(RegExp('Visible synopsis')), findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.label, contains('Expand'));
expect(data.label, contains('Visible synopsis'));
expect(data.value, 'Available offline');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
semantics.dispose();
});
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
@@ -185,4 +185,96 @@ void main() {
expect(cacheParserCalls, 0);
expect(responseParserCalls, 0);
});
group('fetchWithCacheFallback error selection', () {
const cachedBody = {'value': 'cached'};
test('omitted selector preserves fallback on an HTTP 500', () async {
const key = '/metadata/default-fallback';
await cache.put(client.serverId, key, cachedBody);
var cacheParserCalls = 0;
var responseParserCalls = 0;
final value = await client.fetchWithCacheFallback<String>(
cacheKey: key,
networkCall: () async =>
MediaServerResponse(statusCode: 500, data: const {'value': 'server error'}, headers: const {}),
parseCache: (cached) {
cacheParserCalls++;
return (cached as Map<String, dynamic>)['value'] as String;
},
parseResponse: (_) {
responseParserCalls++;
return 'network';
},
);
expect(value, 'cached');
expect(cacheParserCalls, 1);
expect(responseParserCalls, 0);
});
test('rejecting selector rethrows an HTTP status without reading cached data', () async {
const key = '/metadata/rejected-fallback';
await cache.put(client.serverId, key, cachedBody);
var cacheParserCalls = 0;
var responseParserCalls = 0;
await expectLater(
client.fetchWithCacheFallback<String>(
cacheKey: key,
networkCall: () async =>
MediaServerResponse(statusCode: 500, data: const {'value': 'server error'}, headers: const {}),
shouldFallback: (error) => error is MediaServerHttpException && error.isTransient,
parseCache: (_) {
cacheParserCalls++;
return 'cached';
},
parseResponse: (_) {
responseParserCalls++;
return 'network';
},
),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 500)),
);
expect(cacheParserCalls, 0);
expect(responseParserCalls, 0);
expect(await cache.get(client.serverId, key), cachedBody);
});
test('accepting selector serves cache after a transient transport failure', () async {
const key = '/metadata/transient-fallback';
await cache.put(client.serverId, key, cachedBody);
final transient = MediaServerHttpException(
type: MediaServerHttpErrorType.connectionTimeout,
message: 'timed out',
);
Object? selectedError;
var cacheParserCalls = 0;
var responseParserCalls = 0;
final value = await client.fetchWithCacheFallback<String>(
cacheKey: key,
networkCall: () => Future<MediaServerResponse>.error(transient),
shouldFallback: (error) {
selectedError = error;
return error is MediaServerHttpException && error.isTransient;
},
parseCache: (cached) {
cacheParserCalls++;
return (cached as Map<String, dynamic>)['value'] as String;
},
parseResponse: (_) {
responseParserCalls++;
return 'network';
},
);
expect(value, 'cached');
expect(identical(selectedError, transient), isTrue);
expect(cacheParserCalls, 1);
expect(responseParserCalls, 0);
});
});
}
@@ -23,6 +23,7 @@ import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/system_shelf_service.dart';
import 'package:provider/provider.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
void main() {
@@ -60,6 +61,15 @@ void main() {
final trackerProviders = <TrackersProvider>[];
final companionProviders = <CompanionRemoteProvider>[];
final disposedActiveIds = <String>[];
final trackerHttpClients = <FakeHttpClient>[];
// The probe instantiates TrackersProvider (four eager auth owners); the
// separate Trakt provider remains lazy in this reduced shell.
const trackerAuthClientsPerProfile = 4;
FakeHttpClient trackerHttpClientFactory() {
final client = FakeHttpClient(200, const <int>[]);
trackerHttpClients.add(client);
return client;
}
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
@@ -97,6 +107,7 @@ void main() {
child: MaterialApp(
home: ProfileSessionScreen.forTesting(
initialPromptHandled: true,
httpClientFactory: trackerHttpClientFactory,
profileShellBuilder: (context) => _ProfileProbeShell(
discoverProviders: discoverProviders,
hiddenProviders: hiddenProviders,
@@ -109,6 +120,9 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile));
final ownerHttpClients = List<FakeHttpClient>.of(trackerHttpClients);
_expectCloseCount(ownerHttpClients, 0);
expect(find.text('active:local-owner'), findsOneWidget);
expect(SystemShelfService().debugActiveOwner, owner.id);
@@ -131,6 +145,10 @@ void main() {
expect(await activeProfile.activate(kids), isTrue);
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile * 2));
final kidsHttpClients = trackerHttpClients.sublist(ownerHttpClients.length);
_expectCloseCount(ownerHttpClients, 1);
_expectCloseCount(kidsHttpClients, 0);
expect(find.text('old profile route'), findsNothing);
expect(find.text('active:local-kids'), findsOneWidget);
@@ -154,19 +172,27 @@ void main() {
await activeProfile.clearActiveProfile();
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile * 3));
final signedOutHttpClients = trackerHttpClients.sublist(ownerHttpClients.length + kidsHttpClients.length);
_expectCloseCount(ownerHttpClients, 1);
_expectCloseCount(kidsHttpClients, 1);
_expectCloseCount(signedOutHttpClients, 0);
expect(SystemShelfService().debugActiveOwner, isNull);
expect(discoverProviders.last.profileId, isNull);
// Companion provider disposal cancels Drift-backed profile watches. Give
// their asynchronous cancellation timers a frame before test invariants
// are checked.
await tester.pumpWidget(const SizedBox.shrink());
for (var i = 0; i < 3; i++) {
await tester.pump(const Duration(milliseconds: 1));
}
await tester.pumpAndSettle();
expect(trackerHttpClients.toSet(), hasLength(trackerAuthClientsPerProfile * 3));
_expectCloseCount(trackerHttpClients, 1);
});
}
void _expectCloseCount(Iterable<FakeHttpClient> clients, int expected) {
for (final client in clients) {
expect(client.closeCount, expected);
}
}
class _ProfileProbeShell extends StatefulWidget {
const _ProfileProbeShell({
required this.discoverProviders,
@@ -10,6 +10,7 @@ import 'package:plezy/services/trackers/tracker_session.dart';
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
final _malStore = trackerAccountStore(TrackerService.mal);
@@ -62,6 +63,31 @@ void main() {
p.dispose();
});
test('forTesting owns one fresh client per eager auth owner', () {
final clients = <FakeHttpClient>[];
final p = TrackersProvider.forTesting(
connectPipeline: _ControlledConnectPipeline(_mal()).call,
httpClientFactory: () {
final client = FakeHttpClient(200, const <int>[]);
clients.add(client);
return client;
},
);
expect(clients, hasLength(4));
expect(clients.toSet(), hasLength(4));
for (final client in clients) {
expect(client.closeCount, 0);
}
p.dispose();
for (final client in clients) {
expect(client.closeCount, 1);
expect(client.isClosed, isTrue);
}
});
test('onActiveProfileChanged loads sessions from per-profile stores', () async {
const uuid = 'profile-1';
await _malStore.save(uuid, _mal(username: 'alice'));
@@ -6,6 +6,7 @@ import 'package:plezy/services/trackers/tracker_constants.dart';
import 'package:plezy/services/trackers/tracker_session.dart';
import 'package:plezy/services/trakt/trakt_sync_service.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
final _store = trackerAccountStore(TrackerService.trakt);
@@ -34,6 +35,25 @@ void main() {
p.dispose();
});
test('owns the injected auth client until disposal', () {
final clients = <FakeHttpClient>[];
final p = TraktAccountProvider(
httpClientFactory: () {
final client = FakeHttpClient(200, const <int>[]);
clients.add(client);
return client;
},
);
expect(clients, hasLength(1));
expect(clients.single.closeCount, 0);
p.dispose();
expect(clients.single.closeCount, 1);
expect(clients.single.isClosed, isTrue);
});
test('onActiveProfileChanged loads stored session and notifies', () async {
// Pre-seed the store for a specific profile uuid.
const uuid = 'profile-1';
+29
View File
@@ -1,3 +1,5 @@
import 'dart:ui' show SemanticsAction;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -202,6 +204,33 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
});
testWidgets('source switcher announces the active source as its value', (tester) async {
TvDetectionService.debugSetAppleTVOverride(false);
final semantics = tester.ensureSemantics();
final sources = await _pumpExplore(tester);
var finder = find.bySemanticsLabel(t.explore.selectSource);
expect(finder, findsOneWidget);
final node = tester.getSemantics(finder);
var data = node.getSemanticsData();
expect(data.value, 'Trakt');
expect(data.flagsCollection.isButton, isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
node.owner!.performAction(node.id, SemanticsAction.tap);
await tester.pumpAndSettle();
await tester.tap(find.text('MyAnimeList'));
await tester.pumpAndSettle();
expect(sources.activeSource?.id, CatalogSourceId.mal);
finder = find.bySemanticsLabel(t.explore.selectSource);
expect(finder, findsOneWidget);
data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, 'MyAnimeList');
expect(data.hasAction(SemanticsAction.tap), isTrue);
semantics.dispose();
});
testWidgets('source switcher exposes every catalog source with its brand logo', (tester) async {
final sources = await _pumpExplore(tester);
tester.state<ExploreScreenState>(find.byType(ExploreScreen)).focusActiveTabIfReady();
@@ -488,6 +488,7 @@ class _SettingsHarness {
required this.theme,
required this.trakt,
required this.trackers,
required this.trackerHttpClients,
required this.seerr,
required this.downloadManager,
required this.downloadProvider,
@@ -501,6 +502,7 @@ class _SettingsHarness {
final ThemeProvider theme;
final TraktAccountProvider trakt;
final TrackersProvider trackers;
final List<FakeHttpClient> trackerHttpClients;
final SeerrAccountProvider seerr;
final DownloadManagerService downloadManager;
final DownloadProvider downloadProvider;
@@ -519,6 +521,11 @@ class _SettingsHarness {
activeProfile.dispose();
await plexHome.dispose();
await database.close();
expect(trackerHttpClients, hasLength(5));
expect(trackerHttpClients.toSet(), hasLength(5));
for (final client in trackerHttpClients) {
expect(client.closeCount, 1);
}
}
}
@@ -545,8 +552,15 @@ Future<_SettingsHarness> _pumpSettingsScreen(
final activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections);
final libraries = LibrariesProvider();
final theme = ThemeProvider();
final trakt = TraktAccountProvider();
final trackers = TrackersProvider();
final trackerHttpClients = <FakeHttpClient>[];
FakeHttpClient trackerHttpClientFactory() {
final client = FakeHttpClient(HttpStatus.ok, const <int>[]);
trackerHttpClients.add(client);
return client;
}
final trakt = TraktAccountProvider(httpClientFactory: trackerHttpClientFactory);
final trackers = TrackersProvider(httpClientFactory: trackerHttpClientFactory);
final seerr = SeerrAccountProvider();
final settingsService = SettingsService.instance;
final storageService = DownloadStorageService.instance;
@@ -582,6 +596,7 @@ Future<_SettingsHarness> _pumpSettingsScreen(
theme: theme,
trakt: trakt,
trackers: trackers,
trackerHttpClients: trackerHttpClients,
seerr: seerr,
downloadManager: downloadManager,
downloadProvider: downloadProvider,
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player/completion_latch.dart';
void main() {
@@ -78,22 +79,41 @@ void main() {
});
group('completionNavigationAction', () {
test('presents a resolved next episode', () {
test('presents a resolved next item', () {
expect(
completionNavigationAction(hasNext: true, adjacentLoadFailed: false),
completionNavigationAction(hasNext: true, adjacentStatus: QueueNavigationStatus.found),
CompletionNavigationAction.presentNext,
);
});
test('retries adjacency instead of exiting after a load failure', () {
test('retries queued movie adjacency instead of exiting after a load failure', () {
expect(
completionNavigationAction(hasNext: false, adjacentLoadFailed: true),
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.failed),
CompletionNavigationAction.retryAdjacent,
);
});
test('does not exit while the initial adjacency load is unresolved', () {
// VideoPlayerScreen initializes adjacency status to failed until its
// fire-and-forget first load commits a resolved status.
expect(
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.failed),
CompletionNavigationAction.retryAdjacent,
);
});
test('exits a standalone movie after adjacency resolves unavailable', () {
expect(
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.unavailable),
CompletionNavigationAction.exit,
);
});
test('exits only after the queue boundary was resolved', () {
expect(completionNavigationAction(hasNext: false, adjacentLoadFailed: false), CompletionNavigationAction.exit);
expect(
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.boundary),
CompletionNavigationAction.exit,
);
});
});
@@ -6,6 +6,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/play_queue.dart';
import 'package:plezy/models/plex/play_queue_response.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
@@ -26,6 +27,17 @@ MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId})
grandparentId: seriesId,
);
MediaItem _jfMovie(String id) => testMediaItem(
id: id,
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Movie $id',
serverId: ServerId('srv-jf'),
);
PlexMediaItem _plexMovie(String id, int playQueueItemId) =>
PlexMediaItem(id: id, kind: MediaKind.movie, title: 'Movie $id', playQueueItemId: playQueueItemId);
MediaItem _plexEpisode(String id, {required String seriesId, int? viewCount}) => testMediaItem(
id: id,
backend: MediaBackend.plex,
@@ -102,7 +114,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('loadAdjacentEpisodes', () {
testWidgets('returns unavailable when no play queue is active for non-series media', (tester) async {
testWidgets('returns unavailable when no play queue is active for a standalone movie', (tester) async {
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final manager = _StubManager(null);
@@ -116,7 +128,7 @@ void main() {
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r),
child: _ProbeWidget(metadata: _jfMovie('42'), onResult: (r) => result = r),
),
);
await tester.pump();
@@ -124,11 +136,218 @@ void main() {
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.unavailable);
expect(result!.previousStatus, QueueNavigationStatus.unavailable);
expect(result!.hasNext, isFalse);
expect(result!.hasPrevious, isFalse);
expect(playback.isQueueActive, isFalse);
});
testWidgets('preserves adjacency for a movie in an active local queue', (tester) async {
final previous = _jfMovie('movie-1');
final current = _jfMovie('movie-2');
final next = _jfMovie('movie-3');
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
playback.setPlaybackFromLocalQueue(
LocalPlayQueue(
id: 'jellyfin:playlist-movies',
items: [previous, current, next],
currentIndex: 1,
backendId: MediaBackend.jellyfin.id,
),
contextKey: 'playlist-movies',
);
final client = _RecordingClient(seriesEpisodes: const []);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(client.seriesQueueCalls, isEmpty);
expect(playback.isQueueActive, isTrue);
expect(playback.shuffleContextKey, 'playlist-movies');
expect(playback.currentQueueItem, same(current));
expect(playback.currentPlayQueueItemID, 1);
expect(playback.loadedItems, [previous, current, next]);
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.found);
expect(result!.previousStatus, QueueNavigationStatus.found);
expect(result!.next, same(next));
expect(result!.previous, same(previous));
expect(result!.hasNext, isTrue);
expect(result!.hasPrevious, isTrue);
});
testWidgets('preserves a local movie collection queue for a current-item clone', (tester) async {
final previous = _jfMovie('movie-1');
final storedCurrent = _jfMovie('movie-2');
final next = _jfMovie('movie-3');
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
playback.setPlaybackFromLocalQueue(
LocalPlayQueue(
id: 'jellyfin:collection-movies',
items: [previous, storedCurrent, next],
currentIndex: 1,
backendId: MediaBackend.jellyfin.id,
),
contextKey: 'collection-movies',
);
final client = _RecordingClient(seriesEpisodes: const []);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
final currentClone = storedCurrent.copyWith(viewOffsetMs: 42);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: currentClone, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(client.seriesQueueCalls, isEmpty);
expect(playback.isQueueActive, isTrue);
expect(playback.shuffleContextKey, 'collection-movies');
expect(playback.currentQueueItem, same(storedCurrent));
expect(playback.currentPlayQueueItemID, 1);
expect(playback.loadedItems, [previous, storedCurrent, next]);
expect(result, isNotNull);
expect(result!.next, same(next));
expect(result!.previous, same(previous));
expect(result!.hasNext, isTrue);
expect(result!.hasPrevious, isTrue);
});
testWidgets('extends a Plex movie queue window for adjacency', (tester) async {
final previous = _plexMovie('movie-1', 1001);
final current = _plexMovie('movie-2', 1002);
final next = _plexMovie('movie-3', 1003);
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
await playback.setPlaybackFromPlayQueue(
PlayQueueResponse(
playQueueID: 77,
playQueueSelectedItemID: 1002,
playQueueShuffled: false,
playQueueTotalCount: 3,
playQueueVersion: 1,
items: [current],
),
'collection-movies',
);
var windowFetches = 0;
String? requestedCenter;
playback.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async {
windowFetches++;
requestedCenter = center;
expect(playQueueId, 77);
return PlayQueueResponse(
playQueueID: playQueueId,
playQueueSelectedItemID: 1002,
playQueueShuffled: false,
playQueueTotalCount: 3,
playQueueVersion: 2,
items: [previous, current, next],
);
});
final manager = _StubManager(null);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(windowFetches, 1);
expect(requestedCenter, '1002');
expect(playback.isQueueActive, isTrue);
expect(playback.playQueueId, 77);
expect(playback.currentQueueItem, same(current));
expect(playback.currentPlayQueueItemID, 1002);
expect(playback.loadedItems, [previous, current, next]);
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.found);
expect(result!.previousStatus, QueueNavigationStatus.found);
expect(result!.next, same(next));
expect(result!.previous, same(previous));
expect(result!.hasNext, isTrue);
expect(result!.hasPrevious, isTrue);
});
testWidgets('does not use an unrelated active queue for a standalone movie', (tester) async {
final queuedPrevious = _jfMovie('queued-1');
final queuedCurrent = _jfMovie('queued-2');
final unrelated = _jfMovie('unrelated');
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
playback.setPlaybackFromLocalQueue(
LocalPlayQueue(
id: 'jellyfin:stale-playlist',
items: [queuedPrevious, queuedCurrent],
currentIndex: 1,
backendId: MediaBackend.jellyfin.id,
),
contextKey: 'stale-playlist',
);
final client = _RecordingClient(seriesEpisodes: const []);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: unrelated, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(client.seriesQueueCalls, isEmpty);
expect(playback.isQueueActive, isTrue);
expect(playback.shuffleContextKey, 'stale-playlist');
expect(playback.currentQueueItem, same(queuedCurrent));
expect(playback.currentPlayQueueItemID, 1);
expect(playback.loadedItems, [queuedPrevious, queuedCurrent]);
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.failed);
expect(result!.previousStatus, QueueNavigationStatus.failed);
expect(result!.hasNext, isFalse);
expect(result!.hasPrevious, isFalse);
});
testWidgets('catches downstream exceptions and reports failed adjacency', (tester) async {
// Required providers are absent, so context.read throws. The service
// converts the exception into an explicit failed result.
@@ -36,6 +36,22 @@ MediaItem _track(String id) => testMediaItem(
String _urlFor(MediaItem track) => 'fake://${track.id}';
class _CallGate {
final Completer<void> _entered = Completer<void>();
final Completer<void> _released = Completer<void>();
Future<void> get entered => _entered.future;
Future<void> block() {
if (!_entered.isCompleted) _entered.complete();
return _released.future;
}
void release() {
if (!_released.isCompleted) _released.complete();
}
}
/// In-memory audio player: records calls, exposes manual stream controllers
/// so tests drive transitions/completion/errors deterministically.
class FakePlayer implements Player {
@@ -91,6 +107,7 @@ class FakePlayer implements Player {
final List<double> volumes = [];
Completer<void>? playGate;
Completer<void>? pauseGate;
final List<_CallGate> _openGates = [];
/// Arming these URIs throws, simulating a native setNext failure.
final Set<String> failingSetNextUris = {};
@@ -104,9 +121,15 @@ class FakePlayer implements Player {
/// consumed by an auto-advance ([emitTransition]).
Media? get armed => _armedMedia;
_CallGate _gateNextOpen() {
final gate = _CallGate();
_openGates.add(gate);
return gate;
}
void emitTransition(String uri) {
_armedMedia = null; // the backend advanced into the armed entry
_state = _state.copyWith(position: Duration.zero, duration: _trackDuration);
_state = _state.copyWith(completed: false, position: Duration.zero, duration: _trackDuration);
trackTransitionCtrl.add(uri);
}
@@ -115,6 +138,16 @@ class FakePlayer implements Player {
completedCtrl.add(true);
}
/// Emits the real boundary contract: completed always pulses, but a
/// transition can follow only when the native playlist still has an arm.
bool emitNaturalBoundary() {
final armed = _armedMedia;
emitCompleted();
if (armed == null) return false;
emitTransition(armed.uri);
return true;
}
void emitError(String message) => errorCtrl.add(PlayerError(message));
void setPosition(Duration position) {
@@ -171,6 +204,11 @@ class FakePlayer implements Player {
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
final gate = _openGates.isEmpty ? null : _openGates.removeAt(0);
if (gate != null) await gate.block();
// PlayerNative.open uses loadfile replace, which drops any armed entry.
// Clear before recording the committed replacement open.
_armedMedia = null;
openedUris.add(media.uri);
_state = _state.copyWith(playing: play, completed: false, position: Duration.zero, duration: _trackDuration);
if (play) playingCtrl.add(true);
@@ -431,15 +469,24 @@ class FakeMusicSourceResolver implements MusicSourceResolver {
final MediaServerClient? client;
final Set<String> failingIds = {};
final Map<String, int> resolveCounts = {};
final Map<String, Completer<void>> resolveGates = {};
final Map<String, List<_CallGate>> _resolveGates = {};
/// Per-track URL overrides (e.g. content:// shapes for offline tracks).
final Map<String, String> urlOverrides = {};
_CallGate _gateNextResolve(String trackId) {
final gate = _CallGate();
(_resolveGates[trackId] ??= []).add(gate);
return gate;
}
@override
Future<MusicSource> resolve(MediaItem track) async {
resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1;
await resolveGates[track.id]?.future;
final gates = _resolveGates[track.id];
final gate = gates == null || gates.isEmpty ? null : gates.removeAt(0);
if (gates?.isEmpty ?? false) _resolveGates.remove(track.id);
if (gate != null) await gate.block();
if (failingIds.contains(track.id)) {
throw StateError('resolve failed for ${track.id}');
}
@@ -712,32 +759,137 @@ void main() {
});
test('a superseded slow gapless resolve cannot overwrite the newly requested arm', () async {
final oldArmGate = Completer<void>();
h.resolver.resolveGates[t2.id] = oldArmGate;
final oldArmGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2]);
await oldArmGate.entered;
expect(h.player.armed, isNull);
h.service.addNext([t3]);
oldArmGate.complete();
oldArmGate.release();
await pumpEventQueue();
expect(h.player.armed?.uri, _urlFor(t3));
});
test('end-of-track sleep invalidates a slow gapless resolve', () async {
final armGate = Completer<void>();
h.resolver.resolveGates[t2.id] = armGate;
final armGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2]);
await armGate.entered;
h.service.setSleepTimer(null, endOfTrack: true);
armGate.complete();
armGate.release();
await pumpEventQueue();
expect(h.service.sleepTimerEndOfTrack, isTrue);
expect(h.player.armed, isNull);
});
test('manual advance cancels a blocked arm until replacement open commits', () async {
final staleArmGate = h.resolver._gateNextResolve(t2.id);
final replacementResolveGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2, t3]);
await staleArmGate.entered;
final replacementOpenGate = h.player._gateNextOpen();
final advance = h.service.next();
await replacementResolveGate.entered;
replacementResolveGate.release();
await replacementOpenGate.entered;
staleArmGate.release();
await pumpEventQueue();
expect(h.resolver.resolveCounts[t3.id], isNull);
expect(h.player.armed, isNull);
expect(h.player.openedUris, [_urlFor(t1)]);
replacementOpenGate.release();
await advance;
await pumpEventQueue();
expect(h.service.currentTrack?.id, t2.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.resolver.resolveCounts[t3.id], 1);
expect(h.player.armed?.uri, _urlFor(t3));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.service.currentIndex, 2);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)], reason: 'C must advance from the native arm');
});
test('queue edits during a replacement open coalesce into the latest post-open arm', () async {
final t4 = _track('t4');
final t5 = _track('t5');
await h.playTracks([t1, t2, t3]);
final replacementOpenGate = h.player._gateNextOpen();
final advance = h.service.next();
await replacementOpenGate.entered;
final latestArmGate = h.resolver._gateNextResolve(t5.id);
h.service.addNext([t4]);
h.service.addNext([t5]);
await pumpEventQueue();
expect(h.resolver.resolveCounts[t4.id], isNull);
expect(h.resolver.resolveCounts[t5.id], isNull);
expect(h.player.armed, isNull);
replacementOpenGate.release();
await advance;
await latestArmGate.entered;
expect(h.resolver.resolveCounts[t4.id], isNull);
expect(h.resolver.resolveCounts[t5.id], 1);
expect(h.player.armed, isNull);
latestArmGate.release();
await pumpEventQueue();
expect(h.service.queue.map((track) => track.id), [t1.id, t2.id, t5.id, t4.id, t3.id]);
expect(h.player.armed?.uri, _urlFor(t5));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t5.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
});
test('completed fallback cancels a blocked arm until replacement open commits', () async {
final staleArmGate = h.resolver._gateNextResolve(t2.id);
final replacementResolveGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2, t3]);
await staleArmGate.entered;
final replacementOpenGate = h.player._gateNextOpen();
h.player.emitCompleted();
await replacementResolveGate.entered;
replacementResolveGate.release();
await replacementOpenGate.entered;
staleArmGate.release();
await pumpEventQueue();
expect(h.resolver.resolveCounts[t3.id], isNull);
expect(h.player.armed, isNull);
replacementOpenGate.release();
await pumpEventQueue();
expect(h.service.currentTrack?.id, t2.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.player.armed?.uri, _urlFor(t3));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
});
test('a slow instant mix cannot replace a newer explicit queue', () async {
final mixGate = Completer<List<MediaItem>>();
h.client.instantMixGate = mixGate;
@@ -941,6 +1093,23 @@ void main() {
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
});
test('failed explicit resume does not publish playing status', () async {
await h.playTracks([t1]);
await h.service.pause();
final gate = Completer<void>();
final denied = StateError('audio focus denied');
h.player.playGate = gate;
final resume = h.service.play();
expect(h.service.status, MusicPlaybackStatus.paused);
final failureExpectation = expectLater(resume, throwsA(same(denied)));
gate.completeError(denied, StackTrace.current);
await failureExpectation;
expect(h.player.playCalls, 1);
expect(h.service.status, MusicPlaybackStatus.paused);
});
test('a late play completion cannot revive a stopped session', () async {
await h.playTracks([t1]);
await h.service.pause();
@@ -34,6 +34,232 @@ void main() {
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
testPlexClient(serverId: publicServerId, profileScopeId: defaultProfileScopeId, handler: handler);
group('Plex item lookup HTTP/cache contract', () {
const jsonHeaders = {'content-type': 'application/json'};
final apis = [(name: 'fetchItem', includeOnDeck: false), (name: 'fetchItemWithOnDeck', includeOnDeck: true)];
String endpointFor(String id) => '/library/metadata/$id';
Map<String, dynamic> itemResponse(String id, {String title = 'Cached item'}) => {
'MediaContainer': {
'librarySectionID': 7,
'librarySectionTitle': 'Movies',
'Metadata': [
{'ratingKey': id, 'type': 'movie', 'title': title, 'summary': 'Cached summary'},
],
},
};
http.Response jsonResponse(Object? body, int statusCode) =>
http.Response(jsonEncode(body), statusCode, headers: jsonHeaders);
Future<Map<String, dynamic>> seedItem(String id, {String title = 'Cached item'}) async {
final response = itemResponse(id, title: title);
await PlexApiCache.instance.put(defaultProfileScopeId.cacheServerId, endpointFor(id), response);
return response;
}
Future<({MediaItem? item, MediaItem? onDeckEpisode})> lookup(
PlexClient client,
String id, {
required bool includeOnDeck,
}) async {
if (includeOnDeck) {
final result = await client.fetchItemWithOnDeck(id);
return result;
}
return (item: await client.fetchItem(id), onDeckEpisode: null);
}
for (final api in apis) {
for (final cacheState in [(name: 'uncached', seed: false), (name: 'cached', seed: true)]) {
test('${api.name} maps a ${cacheState.name} HTTP 404 to no item', () async {
final id = '${api.name}-${cacheState.name}-404';
Map<String, dynamic>? seededResponse;
if (cacheState.seed) {
seededResponse = await seedItem(id);
}
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse({
'MediaContainer': {
'Metadata': [
{'ratingKey': 'must-not-parse', 'type': 'movie', 'title': 'Must Not Parse'},
],
},
}, 404);
});
addTearDown(client.close);
final result = await lookup(client, id, includeOnDeck: api.includeOnDeck);
expect(result.item, isNull);
expect(result.onDeckEpisode, isNull);
expect(requestCount, 1);
final cached = await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id));
if (cacheState.seed) {
expect(cached, seededResponse);
} else {
expect(cached, isNull);
}
});
}
for (final statusCode in [401, 403, 500]) {
test('${api.name} propagates cached HTTP $statusCode instead of serving stale metadata', () async {
final id = '${api.name}-cached-$statusCode';
final seededResponse = await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse(itemResponse('must-not-parse', title: 'Must Not Parse'), statusCode);
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.statusCode, 'statusCode', statusCode)
.having((error) => error.requestUri?.path, 'request path', endpointFor(id)),
),
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id)), seededResponse);
});
}
test('${api.name} serves cached metadata after a transient timeout', () async {
final id = '${api.name}-cached-timeout';
await seedItem(id);
var requestCount = 0;
final client = makeClient((_) {
requestCount++;
return Future<http.Response>.error(TimeoutException('item lookup timed out'));
});
addTearDown(client.close);
final result = await lookup(client, id, includeOnDeck: api.includeOnDeck);
expect(result.item, isNotNull);
expect(result.item!.id, id);
expect(result.item!.title, 'Cached item');
expect(result.onDeckEpisode, isNull);
expect(requestCount, 1);
});
test('${api.name} propagates a transient timeout when no cache row exists', () async {
final id = '${api.name}-uncached-timeout';
var requestCount = 0;
final client = makeClient((_) {
requestCount++;
return Future<http.Response>.error(TimeoutException('item lookup timed out'));
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.type, 'type', MediaServerHttpErrorType.connectionTimeout)
.having((error) => error.isTransient, 'isTransient', isTrue),
),
);
expect(requestCount, 1);
});
test('${api.name} propagates cancellation despite cached metadata', () async {
final id = '${api.name}-cached-cancelled';
final seededResponse = await seedItem(id);
var requestCount = 0;
final client = makeClient((request) {
requestCount++;
return Future<http.Response>.error(http.RequestAbortedException(request.url));
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.isCancellation, 'isCancellation', isTrue)
.having((error) => error.requestUri?.path, 'request path', endpointFor(id)),
),
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id)), seededResponse);
});
test('${api.name} propagates malformed JSON despite cached metadata', () async {
final id = '${api.name}-cached-malformed-json';
final seededResponse = await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return http.Response('{', 200, headers: jsonHeaders);
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.type, 'type', MediaServerHttpErrorType.unknown)
.having((error) => error.statusCode, 'statusCode', 200),
),
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id)), seededResponse);
});
test('${api.name} propagates a valid JSON payload rejected by the item DTO', () async {
final id = '${api.name}-cached-unmappable';
await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse({
'MediaContainer': {
'Metadata': [
{'ratingKey': id, 'type': 'movie', 'title': 7},
],
},
}, 200);
});
addTearDown(client.close);
await expectLater(lookup(client, id, includeOnDeck: api.includeOnDeck), throwsA(isA<TypeError>()));
expect(requestCount, 1);
});
test('${api.name} uses profile-scoped cache without HTTP while explicitly offline', () async {
final id = '${api.name}-offline';
await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse(const {'mustNotReachNetwork': true}, 500);
});
addTearDown(client.close);
client.setOfflineMode(true);
final result = await lookup(client, id, includeOnDeck: api.includeOnDeck);
expect(result.item, isNotNull);
expect(result.item!.id, id);
expect(result.item!.title, 'Cached item');
expect(result.onDeckEpisode, isNull);
expect(requestCount, 0);
});
}
});
group('Plex mutation result families', () {
test('void mutation completes on success and preserves status/transport failures', () async {
final item = testMediaItem(
+4 -2
View File
@@ -171,7 +171,7 @@ void main() {
final shaders = await ShaderAssetLoader.getShadersForPreset(customPreset(storedName));
expect(shaders, hasLength(1));
expect(path.dirname(shaders.single), path.join(supportDirectory.path, 'custom_shaders'));
expect(path.equals(path.dirname(shaders.single), path.join(supportDirectory.path, 'custom_shaders')), isTrue);
expect(await File(shaders.single).readAsString(), 'shader');
await ShaderAssetLoader.deleteCustomShader(storedName);
@@ -185,7 +185,9 @@ void main() {
final managedFile = File(path.join(customDirectory.path, storedName))..writeAsStringSync('legacy');
expect(ShaderAssetLoader.isValidCustomShaderFileName(storedName), isTrue);
expect(await ShaderAssetLoader.getShadersForPreset(customPreset(storedName)), [managedFile.path]);
final shaders = await ShaderAssetLoader.getShadersForPreset(customPreset(storedName));
expect(shaders, hasLength(1));
expect(path.equals(shaders.single, managedFile.path), isTrue);
await ShaderAssetLoader.deleteCustomShader(storedName);
expect(managedFile.existsSync(), isFalse);
+82 -3
View File
@@ -178,6 +178,15 @@ MediaSourceInfo _mediaInfoWithSubtitles({bool selected = false}) {
);
}
MediaSourceInfo _metadataFreeDirectMediaInfo({bool selected = true}) {
return MediaSourceInfo(
videoUrl: 'https://example.com/video.mp4',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [MediaSubtitleTrack(id: 20, codec: 'ass', selected: selected, forced: false)],
chapters: const [],
);
}
Future<void> _drainAsync() async {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(Duration.zero);
@@ -373,6 +382,58 @@ void main() {
expect(player.selectedSubtitle.single.id, 'no');
});
test('complete metadata-free direct catalog applies tracks without the five-second fallback', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
fakeAsync((async) {
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: 'native-audio', language: 'eng')],
subtitle: [SubtitleTrack(id: 'native-ass', codec: 'ass')],
),
);
final mgr = _make(
player: player,
mediaInfo: _metadataFreeDirectMediaInfo(),
preferredSubtitleTrack: const SubtitleTrack(id: 'source:20', codec: 'ass'),
);
mgr.applyTrackSelectionWhenReady();
async.flushMicrotasks();
expect(player.selectedAudio.map((track) => track.id), ['native-audio']);
expect(player.selectedSubtitle.map((track) => track.id), ['native-ass']);
expect(player.rates, [1.5]);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
test('server-selected metadata-free direct catalog applies without the five-second fallback', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.defaultPlaybackSpeed, 1.25);
fakeAsync((async) {
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: 'native-audio', language: 'eng')],
subtitle: [SubtitleTrack(id: 'native-ass', codec: 'ass')],
),
);
final mgr = _make(player: player, mediaInfo: _metadataFreeDirectMediaInfo());
mgr.applyTrackSelectionWhenReady();
async.flushMicrotasks();
expect(player.selectedAudio.map((track) => track.id), ['native-audio']);
expect(player.selectedSubtitle.map((track) => track.id), ['native-ass']);
expect(player.rates, [1.25]);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
test('waits through a partial catalog until the selected Plex subtitle arrives', () async {
await SettingsService.getInstance();
final player = _FakePlayer(
@@ -380,7 +441,16 @@ void main() {
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/video.mp4',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [
MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: false, forced: false),
],
chapters: const [],
);
final mgr = _make(player: player, mediaInfo: mediaInfo);
addTearDown(mgr.dispose);
mgr.applyTrackSelectionWhenReady();
@@ -414,10 +484,19 @@ void main() {
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/video.mp4',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [
MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: false, forced: false),
],
chapters: const [],
);
final mgr = _make(
player: player,
mediaInfo: _mediaInfoWithSubtitles(selected: true),
preferredSubtitleTrack: const SubtitleTrack(id: 'previous', language: 'fre'),
mediaInfo: mediaInfo,
preferredSubtitleTrack: const SubtitleTrack(id: 'source:11', language: 'fre'),
);
addTearDown(mgr.dispose);
@@ -31,10 +31,10 @@ import '../test_helpers/media_items.dart';
// 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).
// Top-level subtitle matching helpers are exercised directly for complete,
// partial, unique, ambiguous, and container catalogs. Audio helpers are
// exercised through `selectAudioTrack` (Priority 2) and their focused
// disambiguation tests below.
//
// What's NOT covered:
// - `selectAndApplyTracks` — depends on a real Player + SettingsService
@@ -478,6 +478,86 @@ void main() {
expect(result.track.language, 'fre');
});
test('complete metadata-free direct catalog selects its unique native subtitle', () {
final sourceTrack = _plexSub(20, codec: 'ass', selected: true);
final info = _info(subs: [sourceTrack]);
final nativeTrack = _sub('native-ass', codec: 'ass');
final service = _svc(info: info);
final preferredResult = service.selectSubtitleTrack(
[nativeTrack],
const SubtitleTrack(id: 'source:20', codec: 'ass'),
null,
)!;
final serverResult = service.selectSubtitleTrack([nativeTrack], null, null)!;
expect(preferredResult.priority, TrackSelectionPriority.navigation);
expect(preferredResult.track, same(nativeTrack));
expect(serverResult.priority, TrackSelectionPriority.serverSelected);
expect(serverResult.track, same(nativeTrack));
expect(findMpvTrackForPlexSubtitle(sourceTrack, [nativeTrack], allPlexTracks: [sourceTrack]), same(nativeTrack));
expect(findPlexTrackForMpvSubtitle(nativeTrack, [sourceTrack], allMpvTracks: [nativeTrack]), same(sourceTrack));
});
test('complete low-metadata direct catalog uses facts instead of ordinal order', () {
final sourceAss = _plexSub(30, codec: 'ass', selected: true);
final sourceSrt = _plexSub(31, codec: 'srt');
final plexTracks = [sourceAss, sourceSrt];
final nativeSrt = _sub('native-srt', codec: 'srt');
final nativeAss = _sub('native-ass', codec: 'ass');
final nativeTracks = [nativeSrt, nativeAss];
expect(findMpvTrackForPlexSubtitle(sourceAss, nativeTracks, allPlexTracks: plexTracks), same(nativeAss));
expect(findPlexTrackForMpvSubtitle(nativeAss, plexTracks, allMpvTracks: nativeTracks), same(sourceAss));
});
test('ambiguous metadata-free direct catalog does not use ordinal fallback', () {
final selectedSource = _plexSub(40, codec: 'ass', selected: true);
final otherSource = _plexSub(41, codec: 'ass');
final plexTracks = [selectedSource, otherSource];
final nativeTracks = [_sub('native-second', codec: 'ass'), _sub('native-first', codec: 'ass')];
final service = _svc(info: _info(subs: plexTracks));
expect(findMpvTrackForPlexSubtitle(selectedSource, nativeTracks, allPlexTracks: plexTracks), isNull);
expect(findPlexTrackForMpvSubtitle(nativeTracks.first, plexTracks, allMpvTracks: nativeTracks), isNull);
final preferredResult = service.selectSubtitleTrack(
nativeTracks,
const SubtitleTrack(id: 'source:40', codec: 'ass'),
null,
)!;
final serverResult = service.selectSubtitleTrack(nativeTracks, null, null)!;
expect(preferredResult.priority, TrackSelectionPriority.off);
expect(preferredResult.track.id, SubtitleTrack.off.id);
expect(serverResult.priority, TrackSelectionPriority.off);
expect(serverResult.track.id, SubtitleTrack.off.id);
});
test('ambiguous complete direct catalog falls through to native default', () {
final plexTracks = [_plexSub(50, codec: 'ass', selected: true), _plexSub(51, codec: 'ass')];
final nativeTracks = [_sub('native-first', codec: 'ass'), _sub('native-default', codec: 'ass', isDefault: true)];
final result = _svc(
info: _info(subs: plexTracks),
).selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:50', codec: 'ass'), null)!;
expect(result.priority, TrackSelectionPriority.defaultTrack);
expect(result.track.id, 'native-default');
});
test('partial metadata-free direct catalog remains pending', () {
final plexTracks = [_plexSub(60, codec: 'ass', selected: true), _plexSub(61, codec: 'ass')];
final nativeTracks = [_sub('native-only', codec: 'ass', isDefault: true)];
final service = _svc(info: _info(subs: plexTracks));
expect(
service.selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:60', codec: 'ass'), null),
isNull,
);
expect(service.selectSubtitleTrack(nativeTracks, null, null), isNull);
});
test('partial native catalog stays undetermined until the selected Plex track arrives', () {
final info = _info(
subs: [
+137 -45
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryExecutorUser, QueryInterceptor;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -10,6 +11,35 @@ import 'package:plezy/database/tvos_database_recovery_store.dart';
import 'package:plezy/main.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'test_helpers/prefs.dart';
final class _OpenTrackingInterceptor extends QueryInterceptor {
_OpenTrackingInterceptor({this.failure});
final Object? failure;
var ensureOpenCalls = 0;
var ensureOpenCompleted = false;
var closed = false;
@override
Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
ensureOpenCalls++;
final failure = this.failure;
if (failure != null) throw failure;
final result = await executor.ensureOpen(user);
ensureOpenCompleted = true;
return result;
}
@override
Future<void> close(QueryExecutor inner) async {
await inner.close();
closed = true;
}
}
void main() {
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
@@ -112,56 +142,118 @@ void main() {
expect(find.text('ready 9'), findsNothing);
});
test('storage-full database open discards native work before retrying', () async {
final database = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(database.close);
await database.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'active',
globalKey: 'srv:active',
type: 'movie',
status: DownloadStatus.downloading.index,
);
await database.updateBgTaskId('srv:active', 'native-task');
await database.addToQueue(mediaGlobalKey: 'srv:active');
await database.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'complete',
globalKey: 'srv:complete',
type: 'movie',
status: DownloadStatus.completed.index,
test('storage-full lazy database open discards native work before retrying', () async {
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_storage_full_');
final file = File('${tempDir.path}/plezy_downloads.db');
final prefs = await BaseSharedPreferencesService.sharedCache();
final failedOpen = _OpenTrackingInterceptor(
failure: const FileSystemException('write failed: No space left on device'),
);
final successfulOpen = _OpenTrackingInterceptor();
AppDatabase? seeded;
AppDatabase? resultDatabase;
try {
seeded = AppDatabase.forTesting(NativeDatabase(file));
await seeded.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'active',
globalKey: 'srv:active',
type: 'movie',
status: DownloadStatus.downloading.index,
);
await seeded.updateBgTaskId('srv:active', 'native-task');
await seeded.addToQueue(mediaGlobalKey: 'srv:active');
await seeded.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'complete',
globalKey: 'srv:complete',
type: 'movie',
status: DownloadStatus.completed.index,
);
await seeded.close();
seeded = null;
var openAttempts = 0;
var recoveries = 0;
final bootstrap = await openAppDatabaseWithDownloadRecovery(
openDatabase: () {
return AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) {
openAttempts++;
final interceptor = openAttempts == 1 ? failedOpen : successfulOpen;
return NativeDatabase(databaseFile).interceptWith(interceptor);
},
);
},
recoverNativeDownloads: () async {
expect(failedOpen.closed, isTrue);
recoveries++;
},
storageFullMessage: 'Storage full',
);
resultDatabase = bootstrap.database;
final active = await resultDatabase.getDownloadedMedia('srv:active');
final complete = await resultDatabase.getDownloadedMedia('srv:complete');
expect(bootstrap.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable);
expect(openAttempts, 2);
expect(recoveries, 1);
expect(failedOpen.ensureOpenCalls, 1);
expect(successfulOpen.ensureOpenCalls, greaterThanOrEqualTo(1));
expect(successfulOpen.ensureOpenCompleted, isTrue);
expect(active?.status, DownloadStatus.failed.index);
expect(active?.bgTaskId, isNull);
expect(active?.errorMessage, 'Storage full');
expect(complete?.status, DownloadStatus.completed.index);
expect(await resultDatabase.select(resultDatabase.downloadQueue).get(), isEmpty);
expect(await resultDatabase.customSelect('SELECT 1').get(), isNotEmpty);
} finally {
await resultDatabase?.close();
await seeded?.close();
await tempDir.delete(recursive: true);
}
});
test('non-storage lazy database-open errors bypass download recovery', () async {
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_open_error_');
final file = File('${tempDir.path}/plezy_downloads.db');
final prefs = await BaseSharedPreferencesService.sharedCache();
final error = StateError('injected database setup failure');
final failedOpen = _OpenTrackingInterceptor(failure: error);
var openAttempts = 0;
var recoveries = 0;
final bootstrap = AppDatabaseBootstrap(
database: database,
recoveryOutcome: TvosDatabaseRecoveryOutcome.notApplicable,
);
final result = await openAppDatabaseWithDownloadRecovery(
openDatabase: () async {
openAttempts++;
if (openAttempts == 1) {
throw const FileSystemException('write failed: No space left on device');
}
return bootstrap;
},
recoverNativeDownloads: () async {
recoveries++;
},
storageFullMessage: 'Storage full',
);
try {
final open = openAppDatabaseWithDownloadRecovery(
openDatabase: () {
return AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) {
openAttempts++;
return NativeDatabase(databaseFile).interceptWith(failedOpen);
},
);
},
recoverNativeDownloads: () async {
recoveries++;
},
storageFullMessage: 'Storage full',
);
final active = await database.getDownloadedMedia('srv:active');
final complete = await database.getDownloadedMedia('srv:complete');
expect(result, same(bootstrap));
expect(openAttempts, 2);
expect(recoveries, 1);
expect(active?.status, DownloadStatus.failed.index);
expect(active?.bgTaskId, isNull);
expect(active?.errorMessage, 'Storage full');
expect(complete?.status, DownloadStatus.completed.index);
expect(await database.select(database.downloadQueue).get(), isEmpty);
await expectLater(open, throwsA(same(error)));
expect(failedOpen.ensureOpenCalls, 1);
expect(failedOpen.closed, isTrue);
expect(openAttempts, 1);
expect(recoveries, 0);
} finally {
await tempDir.delete(recursive: true);
}
});
}
+8
View File
@@ -36,9 +36,17 @@ class FakeHttpClient extends http.BaseClient {
final int statusCode;
final List<int> body;
int closeCount = 0;
bool get isClosed => closeCount != 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
}
@override
void close() {
closeCount++;
super.close();
}
}
@@ -34,6 +34,9 @@ class FakeSyncPlayer implements Player {
/// When set, the next command throws this and clears the field.
Object? nextCommandError;
/// When set, the next command waits for this future and clears the field.
Future<void>? nextCommandFuture;
/// Simulates bitstream audio ignoring rate changes: setRate succeeds but
/// neither state nor the rate stream reflect it.
bool ignoreRateChanges = false;
@@ -92,6 +95,9 @@ class FakeSyncPlayer implements Player {
Future<void> play() async {
commandLog.add('play');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
if (_state.playing) return;
_state = _state.copyWith(playing: true);
_playingController.add(true);
@@ -101,6 +107,9 @@ class FakeSyncPlayer implements Player {
Future<void> pause() async {
commandLog.add('pause');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
if (!_state.playing) return;
_state = _state.copyWith(playing: false);
_playingController.add(false);
@@ -110,6 +119,9 @@ class FakeSyncPlayer implements Player {
Future<void> seek(Duration position) async {
commandLog.add('seek:${position.inMilliseconds}');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
_state = _state.copyWith(position: position);
if (emitRestartOnSeek) _playbackRestartController.add(null);
}
@@ -118,6 +130,9 @@ class FakeSyncPlayer implements Player {
Future<void> setRate(double rate) async {
commandLog.add('rate:$rate');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
if (ignoreRateChanges || _state.rate == rate) return;
_state = _state.copyWith(rate: rate);
_rateController.add(rate);
+33 -4
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -131,18 +133,45 @@ void main() {
});
});
test('non-recoverable PlatformException rethrows', () {
test('pending teardown NOT_INITIALIZED reports failure and fires onLost once', () {
fakeAsync((async) {
final (attached, player, lostEvents) = build(async);
final playingIntents = <bool>[];
attached.playingIntents.listen(playingIntents.add);
final pending = Completer<void>();
player.nextCommandFuture = pending.future;
bool? result;
attached.play().then((value) => result = value);
async.flushMicrotasks();
expect(result, isNull);
pending.completeError(PlatformException(code: 'NOT_INITIALIZED'));
async.flushMicrotasks();
expect(result, isFalse);
expect(lostEvents, ['lost']);
player.emitPlaying(true);
async.flushMicrotasks();
expect(playingIntents, [true], reason: 'the failed command must remove its outstanding expectation');
attached.dispose();
});
});
test('SET_PROPERTY_FAILED remains non-recoverable', () {
fakeAsync((async) {
final (attached, player, lostEvents) = build(async);
player.nextCommandError = PlatformException(code: 'SOMETHING_ELSE');
player.nextCommandError = PlatformException(code: 'SET_PROPERTY_FAILED');
Object? error;
attached.play().catchError((Object e) {
error = e;
attached.play().catchError((Object caught) {
error = caught;
return false;
});
async.flushMicrotasks();
expect(error, isA<PlatformException>());
expect((error as PlatformException).code, 'SET_PROPERTY_FAILED');
expect(lostEvents, isEmpty);
attached.dispose();
});
@@ -1,4 +1,7 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/watch_together/models/watch_session.dart';
@@ -69,6 +72,48 @@ void main() {
expect(harness.onLeaveSessionCalls, 1);
expect(tester.takeException(), isNull);
});
testWidgets('session controls announce live state and the copyable room code', (tester) async {
final semantics = tester.ensureSemantics();
final harness = _OverlayHarness(isHost: false);
addTearDown(harness.dispose);
await tester.pumpWidget(harness.build());
var finder = find.bySemanticsLabel(t.watchTogether.openSessionControls);
expect(finder, findsOneWidget);
var data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, '${t.watchTogether.participants}: 1');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
harness.provider.updateStatus(isHost: true, isSyncing: true);
await tester.pump();
finder = find.bySemanticsLabel(t.watchTogether.openSessionControls);
data = tester.getSemantics(finder).getSemanticsData();
expect(
data.value,
['${t.watchTogether.participants}: 1', t.watchTogether.youAreHost, t.watchTogether.syncing].join(', '),
);
await tester.tap(find.byKey(_OverlayHarness.indicatorKey));
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
final codeFinder = find.bySemanticsLabel(t.watchTogether.copySessionCode);
expect(codeFinder, findsOneWidget);
data = tester.getSemantics(codeFinder).getSemanticsData();
expect(data.value, 'ROOM42');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel('ROOM42'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
semantics.dispose();
});
}
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
@@ -118,7 +163,8 @@ class _OverlayHarness {
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
final bool isHostValue;
bool isHostValue;
bool isSyncingValue = false;
final Object? leaveError;
var leaveCalls = 0;
var _isDisposing = false;
@@ -126,6 +172,9 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
@override
bool get isHost => isHostValue;
@override
bool get isSyncing => isSyncingValue;
@override
String? get sessionId => 'ROOM42';
@@ -140,6 +189,12 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
@override
int get participantCount => participants.length;
void updateStatus({required bool isHost, required bool isSyncing}) {
isHostValue = isHost;
isSyncingValue = isSyncing;
notifyListeners();
}
@override
Future<void> leaveSession() async {
if (!_isDisposing) leaveCalls++;
@@ -153,3 +208,17 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
super.dispose();
}
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
+73 -1
View File
@@ -1,10 +1,15 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/widgets/collapsible_text.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('select expands overflowing focused text', (tester) async {
final focusNode = FocusNode(debugLabel: 'test_collapsible_text');
addTearDown(focusNode.dispose);
@@ -76,6 +81,53 @@ void main() {
semantics.dispose();
});
testWidgets('overflowing synopsis merges visible text with one expand action', (tester) async {
final semantics = tester.ensureSemantics();
const text =
'This program summary is intentionally long enough to overflow a narrow details sheet and reveal more detail.';
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(width: 120, child: CollapsibleText(text: text, maxLines: 1)),
),
),
);
final collapsedSynopsis = _visibleSynopsis(tester);
expect(collapsedSynopsis, isNotEmpty);
expect(collapsedSynopsis, isNot(text));
var annotation = find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == t.accessibility.expandText,
);
expect(annotation, findsOneWidget);
var node = tester.getSemantics(annotation);
var data = node.getSemanticsData();
expect(data.label, contains(collapsedSynopsis));
expect(data.label, contains(t.accessibility.expandText));
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
node.owner!.performAction(node.id, SemanticsAction.tap);
await tester.pump();
expect(_visibleSynopsis(tester), text);
annotation = find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == t.accessibility.collapseText,
);
expect(annotation, findsOneWidget);
node = tester.getSemantics(annotation);
data = node.getSemanticsData();
expect(data.label, contains(text));
expect(data.label, contains(t.accessibility.collapseText));
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
semantics.dispose();
});
testWidgets('reports whether text overflows', (tester) async {
bool? overflows;
@@ -103,3 +155,23 @@ String _collapsiblePlainText(WidgetTester tester) {
final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null);
return tester.widget<Text>(textFinder).textSpan!.toPlainText();
}
String _visibleSynopsis(WidgetTester tester) {
final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null);
final span = tester.widget<Text>(textFinder).textSpan! as TextSpan;
return (span.children!.first as TextSpan).text!;
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
+38
View File
@@ -0,0 +1,38 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/trackers/device_code.dart';
import 'package:plezy/widgets/device_code_dialog.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('copy control exposes the activation code as its value', (tester) async {
final semantics = tester.ensureSemantics();
const code = DeviceCode(
deviceCode: 'device-token',
userCode: 'ABCD-EFGH',
verificationUrl: 'https://example.com/activate',
expiresIn: 600,
interval: 5,
);
await tester.pumpWidget(
MaterialApp(
home: DeviceCodeDialog(code: code, serviceName: 'Example', onCancel: () {}),
),
);
final finder = find.bySemanticsLabel(t.services.deviceCode.copyCode);
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, code.userCode);
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel(code.userCode), findsNothing);
semantics.dispose();
});
}
@@ -1,4 +1,7 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -34,4 +37,46 @@ void main() {
expect(find.text('One'), findsOneWidget);
});
testWidgets('forwards a scalar value on one icon-only menu node', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusablePopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
semanticLabel: 'Choose source',
semanticValue: 'Trakt',
itemBuilder: (_) => const [AppMenuItem(value: 'one', label: 'One')],
),
),
),
);
final finder = find.bySemanticsLabel('Choose source');
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, 'Trakt');
expect(data.flagsCollection.isButton, isTrue);
expect(_semanticTapNodeCount(tester), 1);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel('Trakt'), findsNothing);
semantics.dispose();
});
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
+36
View File
@@ -169,6 +169,42 @@ void main() {
expect(saved.single!.modifiers, [HotKeyModifier.control]);
});
testWidgets('record control announces and updates its displayed chord', (tester) async {
final semantics = tester.ensureSemantics();
const initial = HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.control]);
final initialValue = [
physicalKeyLabel(PhysicalKeyboardKey.controlLeft),
physicalKeyLabel(PhysicalKeyboardKey.keyP),
].join(' + ');
expect(formatHotKeyDisplay(initial), initialValue);
await _pumpRecorder(tester, saved: <HotKey?>[], currentHotKey: initial);
Finder annotation(String label) =>
find.byWidgetPredicate((widget) => widget is Semantics && widget.properties.label == label);
var finder = annotation(t.hotkeys.pressToRecord);
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, initialValue);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
finder = annotation(t.hotkeys.recordingShortcut);
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, initialValue);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK, physicalKey: PhysicalKeyboardKey.keyK);
await _pumpFocusChange(tester);
const updated = HotKey(key: PhysicalKeyboardKey.keyK);
final updatedValue = physicalKeyLabel(PhysicalKeyboardKey.keyK);
expect(formatHotKeyDisplay(updated), updatedValue);
finder = annotation(t.hotkeys.pressToRecord);
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, updatedValue);
expect(find.bySemanticsLabel(updatedValue), findsNothing);
semantics.dispose();
});
for (final entry in <(String, LogicalKeyboardKey, PhysicalKeyboardKey)>[
('Enter', LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter),
('select', LogicalKeyboardKey.select, PhysicalKeyboardKey.select),
+34
View File
@@ -210,6 +210,40 @@ void main() {
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout)
});
testWidgets('details control announces the current title and artist exactly once', (tester) async {
final semantics = tester.ensureSemantics();
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
var finder = find.bySemanticsLabel('Dawn');
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, 'Test Artist');
expect(find.bySemanticsLabel('Test Artist'), findsNothing);
service.advanceTo(
testMediaItem(
id: 'track_2',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Noon',
grandparentId: 'artist_2',
grandparentTitle: 'Second Artist',
durationMs: 180000,
serverId: 'server_1',
),
);
await tester.pumpAndSettle();
finder = find.bySemanticsLabel('Noon');
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, 'Second Artist');
expect(find.bySemanticsLabel('Second Artist'), findsNothing);
semantics.dispose();
});
testWidgets('progress resets immediately when the current track changes', (tester) async {
final nextTrack = testMediaItem(
id: 'track_2',
+36
View File
@@ -0,0 +1,36 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/services/trackers/oauth_proxy_client.dart';
import 'package:plezy/widgets/oauth_proxy_dialog.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('copy control exposes the OAuth URL as its value', (tester) async {
final semantics = tester.ensureSemantics();
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1024, 768);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
const start = OAuthProxyStart(session: 'session-token', url: 'https://example.com/oauth/start', expiresIn: 600);
await tester.pumpWidget(
MaterialApp(
home: OAuthProxyDialog(start: start, serviceName: 'Example', onCancel: () {}),
),
);
final finder = find.bySemanticsLabel(t.services.oauthProxy.copyUrl);
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, start.url);
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel(start.url), findsNothing);
semantics.dispose();
});
}
+151 -12
View File
@@ -8,6 +8,7 @@ import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_subtitle_resolver.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
@@ -119,7 +120,8 @@ void main() {
expect(switchedChoice, const PlaybackSourceSubtitleChoice.source(0));
});
testWidgets('direct play keeps native tracks and appends unloaded source sidecars', (tester) async {
testWidgets('keeps an unloaded source sidecar selectable', (tester) async {
const remoteUri = 'https://example.test/source/remote.ass';
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')],
@@ -146,7 +148,12 @@ void main() {
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecarIds: const {2},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(remoteUri, title: 'Remote sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (choice) async {
switchedSourceChoice = choice;
},
@@ -161,6 +168,100 @@ void main() {
expect(switchedSourceChoice, const PlaybackSourceSubtitleChoice.source(2));
});
testWidgets('does not duplicate a previously attached sidecar after selecting an embedded track', (tester) async {
const remoteUri = 'https://example.test/source/attached.ass';
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true, uri: remoteUri),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
),
);
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false),
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
codec: 'ass',
external: true,
selected: false,
forced: false,
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(remoteUri, title: 'Remote sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (_) async {},
subtitleSearchSupported: false,
),
);
expect(find.text('Remote sidecar'), findsOneWidget);
});
testWidgets('keeps an unloaded source sidecar beside an unrelated downloaded subtitle', (tester) async {
const sourceUri = 'https://example.test/source/unloaded.ass';
const downloadedUri = 'file:///tmp/downloaded.srt';
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', title: 'Embedded subtitle', codec: 'srt'),
SubtitleTrack(
id: 'downloaded',
title: 'Downloaded subtitle',
codec: 'srt',
isExternal: true,
uri: downloadedUri,
),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', title: 'Embedded subtitle', codec: 'srt'),
),
);
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
codec: 'ass',
external: true,
selected: false,
forced: false,
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(sourceUri, title: 'Remote sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (_) async {},
subtitleSearchSupported: false,
),
);
expect(find.text('Downloaded subtitle'), findsOneWidget);
expect(find.text('Remote sidecar'), findsOneWidget);
});
testWidgets('keeps a source sheet open until the async selection commits', (tester) async {
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
@@ -204,7 +305,16 @@ void main() {
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecarIds: const {2},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(
'https://example.test/source/pending.ass',
title: 'Remote sidecar',
codec: 'ass',
),
),
],
onSwitchSubtitle: (_) => selectionGate.future,
subtitleSearchSupported: false,
),
@@ -262,18 +372,26 @@ void main() {
expect(switchedSourceChoice, isNull);
});
testWidgets('does not append a source sidecar already loaded as the secondary track', (tester) async {
testWidgets('does not append source sidecars already loaded as primary and secondary tracks', (tester) async {
const primaryUri = 'https://example.test/source/primary.ass';
const secondaryUri = 'https://example.test/source/secondary.ass';
final player = _FakeTrackSheetPlayer(
supportsSecondarySubtitles: true,
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri),
SubtitleTrack(id: 's2', title: 'Secondary sidecar', codec: 'ass', isExternal: true, uri: secondaryUri),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
secondarySubtitle: SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
subtitle: SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri),
secondarySubtitle: SubtitleTrack(
id: 's2',
title: 'Secondary sidecar',
codec: 'ass',
isExternal: true,
uri: secondaryUri,
),
),
);
@@ -282,9 +400,17 @@ void main() {
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(
id: 1,
title: 'Primary sidecar',
codec: 'ass',
external: true,
selected: true,
forced: false,
),
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
title: 'Secondary sidecar',
codec: 'ass',
external: true,
selected: false,
@@ -293,13 +419,23 @@ void main() {
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
selectedSecondarySubtitleStreamId: 2,
sourceSubtitleSidecarIds: const {2},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 1,
track: SubtitleTrack.uri(primaryUri, title: 'Primary sidecar', codec: 'ass'),
),
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(secondaryUri, title: 'Secondary sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (_) async {},
subtitleSearchSupported: false,
),
);
expect(find.text('Remote sidecar'), findsOneWidget);
expect(find.text('Primary sidecar'), findsOneWidget);
expect(find.text('Secondary sidecar'), findsOneWidget);
});
});
@@ -364,10 +500,13 @@ void main() {
});
test('counts direct-play source sidecars without replacing native tracks', () {
const sourceUri = 'https://example.test/source/available.ass';
final sourceSidecar = MediaSubtitleTrack(id: 1, external: true, selected: false, forced: false);
final state = TrackControlsState(
sourceSubtitleTracks: [sourceSidecar],
sourceSubtitleSidecarIds: {sourceSidecar.id},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(sourceStreamId: sourceSidecar.id, track: SubtitleTrack.uri(sourceUri)),
],
onSwitchSubtitle: (_) async {},
);
+49
View File
@@ -1099,6 +1099,55 @@ void main() {
expect(seekEvents, 0);
});
testWidgets('focused slider owns one adjustable semantics node', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'semantic_timeline');
addTearDown(focusNode.dispose);
final seekEnds = <Duration>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 1),
duration: const Duration(minutes: 10),
chapters: const [],
chaptersLoaded: true,
focusNode: focusNode,
onSeek: (_) {},
onSeekEnd: seekEnds.add,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
final finder = find.bySemanticsLabel(t.videoControls.timelineSlider);
expect(finder, findsOneWidget);
final node = tester.getSemantics(finder);
final data = node.getSemanticsData();
expect(data.label, t.videoControls.timelineSlider);
expect(data.value, '1:00');
expect(data.increasedValue, '1:10');
expect(data.decreasedValue, '0:50');
expect(data.flagsCollection.isSlider, isTrue);
expect(data.flagsCollection.isEnabled, ui.Tristate.isTrue);
expect(data.flagsCollection.isButton, isFalse);
expect(data.hasAction(ui.SemanticsAction.tap), isFalse);
expect(data.hasAction(ui.SemanticsAction.increase), isTrue);
expect(data.hasAction(ui.SemanticsAction.decrease), isTrue);
node.owner!.performAction(node.id, ui.SemanticsAction.increase);
node.owner!.performAction(node.id, ui.SemanticsAction.decrease);
expect(seekEnds, const [Duration(minutes: 1, seconds: 10), Duration(seconds: 50)]);
semantics.dispose();
});
testWidgets('does not pass chapters to painter when timeline markers are hidden', (tester) async {
await tester.pumpWidget(
MaterialApp(