refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
This commit is contained in:
@@ -20,6 +20,7 @@ import 'package:plezy/utils/active_client_scope.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import '../test_helpers/download_fixtures.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
@@ -68,8 +69,8 @@ class _AppDatabaseTestSuite {
|
||||
// v20 dropped the profile_id FK so virtual Plex Home profiles can
|
||||
// persist join rows without a parent `profiles` row. Profile deletion
|
||||
// instead cleans up join rows explicitly (via the teardown flow's
|
||||
// removeAllProfileConnectionsAndCleanup) before deleting the profile,
|
||||
// so the cascade isn't needed.
|
||||
// ProfileConnectionCleanup.removeAllProfileConnections) before deleting
|
||||
// the profile, so the cascade isn't needed.
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await db
|
||||
.into(db.connections)
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/database/download_operations.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
|
||||
import '../test_helpers/download_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
@@ -19,115 +21,6 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// insertDownload
|
||||
// ============================================================
|
||||
|
||||
group('insertDownload', () {
|
||||
test('inserts a movie row with defaults', () async {
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
|
||||
final rows = await db.select(db.downloadedMedia).get();
|
||||
expect(rows, hasLength(1));
|
||||
final r = rows.first;
|
||||
expect(r.serverId, 'srv');
|
||||
expect(r.ratingKey, '100');
|
||||
expect(r.globalKey, 'srv:100');
|
||||
expect(r.type, 'movie');
|
||||
expect(r.status, DownloadStatus.queued.index);
|
||||
expect(r.parentRatingKey, isNull);
|
||||
expect(r.grandparentRatingKey, isNull);
|
||||
expect(r.mediaIndex, 0);
|
||||
});
|
||||
|
||||
test('inserts an episode with parent and grandparent keys', () async {
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: 'ep1',
|
||||
globalKey: 'srv:ep1',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.queued.index,
|
||||
mediaIndex: 7,
|
||||
);
|
||||
|
||||
final row = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(row.parentRatingKey, 'season1');
|
||||
expect(row.grandparentRatingKey, 'show1');
|
||||
expect(row.mediaIndex, 7);
|
||||
});
|
||||
|
||||
test('atomically updates metadata and attempt state while preserving the row and physical fields', () async {
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
clientScopeId: 'scope-old',
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
mediaIndex: 1,
|
||||
mediaSourceId: 'source-old',
|
||||
);
|
||||
final original = (await db.getDownloadedMedia('srv:100'))!;
|
||||
await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write(
|
||||
const DownloadedMediaCompanion(
|
||||
progress: Value(50),
|
||||
downloadedBytes: Value(500),
|
||||
totalBytes: Value(1000),
|
||||
videoFilePath: Value('downloads/video.mkv'),
|
||||
safRootUri: Value('content://downloads'),
|
||||
thumbPath: Value('downloads/thumb.jpg'),
|
||||
downloadedAt: Value(1234),
|
||||
errorMessage: Value('old error'),
|
||||
retryCount: Value(2),
|
||||
bgTaskId: Value('current-task'),
|
||||
),
|
||||
);
|
||||
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv-new'),
|
||||
clientScopeId: 'scope-new',
|
||||
ratingKey: '100-new',
|
||||
globalKey: 'srv:100',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season-new',
|
||||
grandparentRatingKey: 'show-new',
|
||||
status: DownloadStatus.failed.index,
|
||||
mediaIndex: 3,
|
||||
mediaSourceId: 'source-new',
|
||||
);
|
||||
|
||||
final row = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(row.id, original.id);
|
||||
expect(row.serverId, 'srv-new');
|
||||
expect(row.clientScopeId, 'scope-new');
|
||||
expect(row.ratingKey, '100-new');
|
||||
expect(row.type, 'episode');
|
||||
expect(row.parentRatingKey, 'season-new');
|
||||
expect(row.grandparentRatingKey, 'show-new');
|
||||
expect(row.status, DownloadStatus.failed.index);
|
||||
expect(row.mediaIndex, 3);
|
||||
expect(row.mediaSourceId, 'source-new');
|
||||
expect(row.progress, 0);
|
||||
expect(row.downloadedBytes, 0);
|
||||
expect(row.totalBytes, isNull);
|
||||
expect(row.errorMessage, isNull);
|
||||
expect(row.retryCount, 0);
|
||||
expect(row.videoFilePath, 'downloads/video.mkv');
|
||||
expect(row.safRootUri, 'content://downloads');
|
||||
expect(row.thumbPath, 'downloads/thumb.jpg');
|
||||
expect(row.downloadedAt, 1234);
|
||||
expect(row.bgTaskId, 'current-task');
|
||||
});
|
||||
});
|
||||
|
||||
group('insertQueuedDownload', () {
|
||||
test('atomically persists media identity, scope, policy, and queue state', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_');
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/mixins/paginated_item_loader.dart';
|
||||
import 'package:plezy/mixins/standard_paginated_view.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
@@ -27,10 +28,20 @@ class _PaginatedProbe extends StatefulWidget {
|
||||
State<_PaginatedProbe> createState() => _PaginatedProbeState();
|
||||
}
|
||||
|
||||
class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader<MediaItem, _PaginatedProbe> {
|
||||
class _PaginatedProbeState extends State<_PaginatedProbe>
|
||||
with PaginatedItemLoader<MediaItem, _PaginatedProbe>, StandardPaginatedView<MediaItem, _PaginatedProbe> {
|
||||
int fetchCalls = 0;
|
||||
final List<({int start, int size})> fetchArgs = [];
|
||||
|
||||
/// View fields required by [StandardPaginatedView], mirroring the
|
||||
/// `items`/`isLoading`/`errorMessage` trio the real screens expose.
|
||||
@override
|
||||
List<MediaItem> items = [];
|
||||
@override
|
||||
bool isLoading = false;
|
||||
@override
|
||||
String? errorMessage;
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
|
||||
fetchCalls++;
|
||||
@@ -109,10 +120,8 @@ void main() {
|
||||
expect(hooked, [(0, 5)]);
|
||||
});
|
||||
|
||||
testWidgets('loadInitialPaginatedItems applies reset, data, and success callback', (tester) async {
|
||||
testWidgets('loadStandardPaginatedItems resets view state, publishes items, and fires onLoaded', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
var reset = false;
|
||||
List<MediaItem>? applied;
|
||||
(int, int)? counts;
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
@@ -121,25 +130,26 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final succeeded = await state.loadInitialPaginatedItems(
|
||||
// Stale view state from a previous load must be cleared by the reset.
|
||||
state.items = [_meta(99)];
|
||||
state.errorMessage = 'stale error';
|
||||
|
||||
await state.loadStandardPaginatedItems(
|
||||
pageSize: 3,
|
||||
resetViewState: () => reset = true,
|
||||
applyLoadedItems: (items) => applied = items,
|
||||
applyError: (error, stackTrace) => fail('unexpected error: $error'),
|
||||
errorMessageFor: (error, stackTrace) => fail('unexpected error: $error'),
|
||||
onLoaded: (loaded, total) => counts = (loaded, total),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(succeeded, isTrue);
|
||||
expect(reset, isTrue);
|
||||
expect(applied?.map((item) => item.id), ['k0', 'k1', 'k2']);
|
||||
expect(state.items.map((item) => item.id), ['k0', 'k1', 'k2']);
|
||||
expect(state.isLoading, isFalse);
|
||||
expect(state.errorMessage, isNull);
|
||||
expect(counts, (3, 7));
|
||||
});
|
||||
|
||||
testWidgets('loadInitialPaginatedItems applies one error transaction', (tester) async {
|
||||
testWidgets('loadStandardPaginatedItems applies one error transaction', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
Object? appliedError;
|
||||
Object? loggedError;
|
||||
Object? reportedError;
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
@@ -147,18 +157,20 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final succeeded = await state.loadInitialPaginatedItems(
|
||||
await state.loadStandardPaginatedItems(
|
||||
pageSize: 3,
|
||||
resetViewState: () {},
|
||||
applyLoadedItems: (_) => fail('items must not be applied'),
|
||||
applyError: (error, stackTrace) => appliedError = error,
|
||||
onError: (error, stackTrace) => loggedError = error,
|
||||
errorMessageFor: (error, stackTrace) {
|
||||
reportedError = error;
|
||||
return 'could not load';
|
||||
},
|
||||
onLoaded: (_, _) => fail('items must not be applied'),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(succeeded, isFalse);
|
||||
expect(appliedError, isA<StateError>());
|
||||
expect(loggedError, same(appliedError));
|
||||
expect(reportedError, isA<StateError>());
|
||||
expect(state.errorMessage, 'could not load');
|
||||
expect(state.isLoading, isFalse);
|
||||
expect(state.items, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('totalSize == 0 means no more pages — ensureRangeLoaded is a no-op', (tester) async {
|
||||
|
||||
@@ -83,6 +83,7 @@ void main() {
|
||||
late ConnectionRegistry connections;
|
||||
late ProfileConnectionRegistry profileConnections;
|
||||
late StorageService storage;
|
||||
late ProfileConnectionCleanup cleanup;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
@@ -90,6 +91,11 @@ void main() {
|
||||
connections = ConnectionRegistry(db);
|
||||
profileConnections = ProfileConnectionRegistry(db);
|
||||
storage = await StorageService.getInstance();
|
||||
cleanup = ProfileConnectionCleanup(
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -112,13 +118,7 @@ void main() {
|
||||
await storage.saveHiddenLibraries({'jf-machine:movies'});
|
||||
await storage.saveLibraryOrder(['jf-machine:movies']);
|
||||
|
||||
await removeProfileConnectionAndCleanup(
|
||||
profileId: 'p1',
|
||||
connection: conn,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
await cleanup.removeProfileConnection(profileId: 'p1', connection: conn);
|
||||
|
||||
expect(await profileConnections.listForConnection(conn.id), isEmpty);
|
||||
expect(await connections.get(conn.id), isNull);
|
||||
@@ -151,13 +151,7 @@ void main() {
|
||||
await storage.setActiveProfileId('p2');
|
||||
await storage.saveHiddenLibraries({'jf-machine:movies'});
|
||||
|
||||
await removeProfileConnectionAndCleanup(
|
||||
profileId: 'p1',
|
||||
connection: conn,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
await cleanup.removeProfileConnection(profileId: 'p1', connection: conn);
|
||||
|
||||
expect(await connections.get(conn.id), isNotNull);
|
||||
final remaining = await profileConnections.listForConnection(conn.id);
|
||||
@@ -177,11 +171,7 @@ void main() {
|
||||
await storage.saveHiddenLibraries({'jf-machine:movies'});
|
||||
await storage.saveLibrarySort('jf-machine:movies', 'titleSort');
|
||||
|
||||
final removed = await pruneUnreferencedJellyfinConnections(
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
final removed = await cleanup.pruneUnreferencedJellyfinConnections();
|
||||
|
||||
expect(removed, 1);
|
||||
expect(await connections.get(conn.id), isNull);
|
||||
@@ -205,11 +195,7 @@ void main() {
|
||||
await storage.setActiveProfileId('p2');
|
||||
await storage.saveHiddenLibraries({'jf-machine:movies'});
|
||||
|
||||
final removed = await pruneUnreferencedJellyfinConnections(
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
final removed = await cleanup.pruneUnreferencedJellyfinConnections();
|
||||
|
||||
expect(removed, 1);
|
||||
expect(await connections.get(orphan.id), isNull);
|
||||
@@ -241,13 +227,7 @@ void main() {
|
||||
expect(await connections.get(acct.id), isNotNull);
|
||||
expect(await profileConnections.listAll(), hasLength(2));
|
||||
|
||||
final removal = await removePlexAccountConnectionAndCleanup(
|
||||
account: acct,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
plannedRemoval: plannedRemoval,
|
||||
);
|
||||
final removal = await cleanup.removePlexAccountConnection(acct, plannedRemoval: plannedRemoval);
|
||||
|
||||
expect(removal.removedVirtualProfileIds, {vProfile});
|
||||
expect(removal.borrowerProfileIds, isEmpty);
|
||||
@@ -270,12 +250,7 @@ void main() {
|
||||
await profileConnections.upsert(_row(vProfile, jf));
|
||||
await profileConnections.upsert(_row('local-1', jf));
|
||||
|
||||
await removePlexAccountConnectionAndCleanup(
|
||||
account: acct,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
await cleanup.removePlexAccountConnection(acct);
|
||||
|
||||
expect(await connections.get(jf.id), isNotNull);
|
||||
final remaining = await profileConnections.listAll();
|
||||
@@ -293,12 +268,7 @@ void main() {
|
||||
await profileConnections.upsert(_row('local-1', acct));
|
||||
await profileConnections.upsert(_row('local-1', jf));
|
||||
|
||||
final removal = await removePlexAccountConnectionAndCleanup(
|
||||
account: acct,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
final removal = await cleanup.removePlexAccountConnection(acct);
|
||||
|
||||
expect(removal.removedVirtualProfileIds, isEmpty);
|
||||
expect(removal.borrowerProfileIds, {'local-1'});
|
||||
@@ -323,12 +293,7 @@ void main() {
|
||||
await profileConnections.upsert(_row(v2, acct2, userIdentifier: uuid2));
|
||||
await storage.savePlexHomeUsersCache(acct2.id, [_homeUser(uuid2).toJson()]);
|
||||
|
||||
final removal = await removePlexAccountConnectionAndCleanup(
|
||||
account: acct1,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
final removal = await cleanup.removePlexAccountConnection(acct1);
|
||||
|
||||
expect(removal.removedVirtualProfileIds, {v1});
|
||||
expect(await connections.get(acct2.id), isNotNull);
|
||||
@@ -348,12 +313,7 @@ void main() {
|
||||
await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid));
|
||||
await profileConnections.upsert(_row(vProfile, jf));
|
||||
|
||||
Future<PlexAccountRemoval> run() => removePlexAccountConnectionAndCleanup(
|
||||
account: acct,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
Future<PlexAccountRemoval> run() => cleanup.removePlexAccountConnection(acct);
|
||||
|
||||
await run();
|
||||
final second = await run();
|
||||
@@ -375,13 +335,7 @@ void main() {
|
||||
await storage.setActiveProfileId('p2');
|
||||
await storage.saveHiddenLibraries({'plex-machine:movies'});
|
||||
|
||||
await removeProfileConnectionAndCleanup(
|
||||
profileId: 'p1',
|
||||
connection: conn,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
await cleanup.removeProfileConnection(profileId: 'p1', connection: conn);
|
||||
|
||||
expect(await connections.get(conn.id), isNotNull);
|
||||
expect(await profileConnections.listForConnection(conn.id), isEmpty);
|
||||
@@ -402,13 +356,7 @@ void main() {
|
||||
Future<({PostRemovalRoute route, List<Profile> profiles})> resolve({
|
||||
Map<String, List<PlexHomeUser>> plexHomeUsers = const {},
|
||||
}) {
|
||||
return resolvePostRemovalState(
|
||||
profileRegistry: profileRegistry,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
plexHomeUsers: plexHomeUsers,
|
||||
storage: storage,
|
||||
);
|
||||
return cleanup.resolvePostRemovalState(profileRegistry: profileRegistry, plexHomeUsers: plexHomeUsers);
|
||||
}
|
||||
|
||||
Profile local(String id) =>
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/utils/deletion_notifier.dart';
|
||||
import 'package:plezy/utils/watch_state_notifier.dart';
|
||||
import 'package:plezy/utils/active_client_scope.dart';
|
||||
import '../test_helpers/download_fixtures.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
/// Implements only [fetchPlayableDescendants], the surface [collectEpisodes]
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:plezy/media/media_backend.dart';
|
||||
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/providers/watch_state_store.dart';
|
||||
import 'package:plezy/services/watch_state_resolver.dart';
|
||||
import 'package:plezy/utils/watch_state_notifier.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
@@ -190,13 +191,13 @@ void main() {
|
||||
store.setHydratedPatches(const [
|
||||
HydratedWatchStatePatch(
|
||||
globalKey: 'jf-machine:show-1',
|
||||
patch: WatchStatePatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0),
|
||||
patch: WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0),
|
||||
updatedAt: 100,
|
||||
order: 1,
|
||||
),
|
||||
HydratedWatchStatePatch(
|
||||
globalKey: 'jf-machine:episode-1',
|
||||
patch: WatchStatePatch(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0),
|
||||
patch: WatchStateSnapshot(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0),
|
||||
updatedAt: 200,
|
||||
order: 2,
|
||||
),
|
||||
@@ -213,13 +214,13 @@ void main() {
|
||||
store.setHydratedPatches(const [
|
||||
HydratedWatchStatePatch(
|
||||
globalKey: 'jf-machine/user-a:show-1',
|
||||
patch: WatchStatePatch(isWatched: true),
|
||||
patch: WatchStateSnapshot(isWatched: true),
|
||||
updatedAt: 100,
|
||||
order: 1,
|
||||
),
|
||||
HydratedWatchStatePatch(
|
||||
globalKey: 'jf-machine/user-b:show-1',
|
||||
patch: WatchStatePatch(isWatched: false),
|
||||
patch: WatchStateSnapshot(isWatched: false),
|
||||
updatedAt: 100,
|
||||
order: 2,
|
||||
),
|
||||
|
||||
@@ -33,6 +33,7 @@ import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/utils/active_client_scope.dart';
|
||||
import 'package:saf_util/saf_util_platform_interface.dart';
|
||||
|
||||
import '../test_helpers/download_fixtures.dart';
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
import 'package:plezy/screens/video_player/media_control_router.dart';
|
||||
import 'package:plezy/services/media_control_router.dart';
|
||||
|
||||
void main() {
|
||||
test('denied playback and media-item commands are consumed without mutation', () {
|
||||
@@ -47,12 +47,12 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
VideoPlayerMediaControlRouter _router({
|
||||
MediaControlRouter _router({
|
||||
required bool Function() canControl,
|
||||
required bool Function() canNavigate,
|
||||
required List<String> calls,
|
||||
}) {
|
||||
return VideoPlayerMediaControlRouter(
|
||||
return MediaControlRouter(
|
||||
canControlPlayback: canControl,
|
||||
canNavigateMediaItems: canNavigate,
|
||||
onPlay: () => calls.add('play'),
|
||||
@@ -22,6 +22,7 @@ import 'package:plezy/utils/active_client_scope.dart';
|
||||
import 'package:plezy/utils/watch_state_notifier.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/download_fixtures.dart';
|
||||
import '../test_helpers/playback_report_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:plezy/main.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
|
||||
import 'test_helpers/download_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
|
||||
final completion = Completer<int>();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
|
||||
/// Seeds `downloaded_media` rows at an arbitrary [status] so tests can start
|
||||
/// from completed, downloading, or paused state.
|
||||
///
|
||||
/// Production never writes rows this way — it goes through
|
||||
/// `insertQueuedDownload`, which only ever admits `queued` rows and guards on
|
||||
/// the existing status. This fixture deliberately keeps neither restriction,
|
||||
/// which is why it lives in `test/` instead of `lib/`.
|
||||
extension DownloadFixtures on AppDatabase {
|
||||
Future<void> insertDownload({
|
||||
required ServerId serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String type,
|
||||
String? parentRatingKey,
|
||||
String? grandparentRatingKey,
|
||||
required int status,
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
}) async {
|
||||
await customUpdate(
|
||||
'''
|
||||
INSERT INTO downloaded_media (
|
||||
server_id,
|
||||
client_scope_id,
|
||||
rating_key,
|
||||
global_key,
|
||||
type,
|
||||
parent_rating_key,
|
||||
grandparent_rating_key,
|
||||
status,
|
||||
media_index,
|
||||
media_source_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(global_key) DO UPDATE SET
|
||||
server_id = excluded.server_id,
|
||||
client_scope_id = excluded.client_scope_id,
|
||||
rating_key = excluded.rating_key,
|
||||
type = excluded.type,
|
||||
parent_rating_key = excluded.parent_rating_key,
|
||||
grandparent_rating_key = excluded.grandparent_rating_key,
|
||||
status = excluded.status,
|
||||
progress = 0,
|
||||
total_bytes = NULL,
|
||||
downloaded_bytes = 0,
|
||||
error_message = NULL,
|
||||
retry_count = 0,
|
||||
media_index = excluded.media_index,
|
||||
media_source_id = excluded.media_source_id
|
||||
''',
|
||||
variables: [
|
||||
Variable<String>(serverId),
|
||||
Variable<String>(clientScopeId),
|
||||
Variable<String>(ratingKey),
|
||||
Variable<String>(globalKey),
|
||||
Variable<String>(type),
|
||||
Variable<String>(parentRatingKey),
|
||||
Variable<String>(grandparentRatingKey),
|
||||
Variable<int>(status),
|
||||
Variable<int>(mediaIndex),
|
||||
Variable<String>(mediaSourceId),
|
||||
],
|
||||
updates: {downloadedMedia},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/database/download_operations.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
|
||||
import 'download_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('insertDownload', () {
|
||||
test('inserts a movie row with defaults', () async {
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
|
||||
final rows = await db.select(db.downloadedMedia).get();
|
||||
expect(rows, hasLength(1));
|
||||
final r = rows.first;
|
||||
expect(r.serverId, 'srv');
|
||||
expect(r.ratingKey, '100');
|
||||
expect(r.globalKey, 'srv:100');
|
||||
expect(r.type, 'movie');
|
||||
expect(r.status, DownloadStatus.queued.index);
|
||||
expect(r.parentRatingKey, isNull);
|
||||
expect(r.grandparentRatingKey, isNull);
|
||||
expect(r.mediaIndex, 0);
|
||||
});
|
||||
|
||||
test('inserts an episode with parent and grandparent keys', () async {
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: 'ep1',
|
||||
globalKey: 'srv:ep1',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.queued.index,
|
||||
mediaIndex: 7,
|
||||
);
|
||||
|
||||
final row = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(row.parentRatingKey, 'season1');
|
||||
expect(row.grandparentRatingKey, 'show1');
|
||||
expect(row.mediaIndex, 7);
|
||||
});
|
||||
|
||||
test('atomically updates metadata and attempt state while preserving the row and physical fields', () async {
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
clientScopeId: 'scope-old',
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
mediaIndex: 1,
|
||||
mediaSourceId: 'source-old',
|
||||
);
|
||||
final original = (await db.getDownloadedMedia('srv:100'))!;
|
||||
await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write(
|
||||
const DownloadedMediaCompanion(
|
||||
progress: Value(50),
|
||||
downloadedBytes: Value(500),
|
||||
totalBytes: Value(1000),
|
||||
videoFilePath: Value('downloads/video.mkv'),
|
||||
safRootUri: Value('content://downloads'),
|
||||
thumbPath: Value('downloads/thumb.jpg'),
|
||||
downloadedAt: Value(1234),
|
||||
errorMessage: Value('old error'),
|
||||
retryCount: Value(2),
|
||||
bgTaskId: Value('current-task'),
|
||||
),
|
||||
);
|
||||
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv-new'),
|
||||
clientScopeId: 'scope-new',
|
||||
ratingKey: '100-new',
|
||||
globalKey: 'srv:100',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season-new',
|
||||
grandparentRatingKey: 'show-new',
|
||||
status: DownloadStatus.failed.index,
|
||||
mediaIndex: 3,
|
||||
mediaSourceId: 'source-new',
|
||||
);
|
||||
|
||||
final row = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(row.id, original.id);
|
||||
expect(row.serverId, 'srv-new');
|
||||
expect(row.clientScopeId, 'scope-new');
|
||||
expect(row.ratingKey, '100-new');
|
||||
expect(row.type, 'episode');
|
||||
expect(row.parentRatingKey, 'season-new');
|
||||
expect(row.grandparentRatingKey, 'show-new');
|
||||
expect(row.status, DownloadStatus.failed.index);
|
||||
expect(row.mediaIndex, 3);
|
||||
expect(row.mediaSourceId, 'source-new');
|
||||
expect(row.progress, 0);
|
||||
expect(row.downloadedBytes, 0);
|
||||
expect(row.totalBytes, isNull);
|
||||
expect(row.errorMessage, isNull);
|
||||
expect(row.retryCount, 0);
|
||||
expect(row.videoFilePath, 'downloads/video.mkv');
|
||||
expect(row.safRootUri, 'content://downloads');
|
||||
expect(row.thumbPath, 'downloads/thumb.jpg');
|
||||
expect(row.downloadedAt, 1234);
|
||||
expect(row.bgTaskId, 'current-task');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:plezy/mpv/player/player_streams.dart';
|
||||
import 'package:plezy/screens/settings/subtitle_styling_screen.dart';
|
||||
import 'package:plezy/services/sleep_timer_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
|
||||
import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
@@ -147,10 +148,8 @@ Future<void> _pumpSheet(
|
||||
height: 700,
|
||||
child: VideoSettingsSheet(
|
||||
player: player ?? _FakeSettingsPlayer(),
|
||||
audioSyncOffset: 0,
|
||||
subtitleSyncOffset: 0,
|
||||
canControl: canControl,
|
||||
supportsHdrControl: supportsHdrControl,
|
||||
trackControlsState: TrackControlsState(canControl: canControl),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user