fix(app): restore playback and state lifecycle contracts
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 {},
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user