refactor(core): consolidate shared app foundations
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/focusable_action_bar.dart';
|
||||
import 'package:plezy/focus/focusable_text_field.dart';
|
||||
import 'package:plezy/focus/focusable_wrapper.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('FocusableWrapper never disposes caller-owned nodes across swaps', (tester) async {
|
||||
final first = _TrackingFocusNode();
|
||||
final second = _TrackingFocusNode();
|
||||
late StateSetter rebuild;
|
||||
var node = first;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
rebuild = setState;
|
||||
return FocusableWrapper(focusNode: node, child: const SizedBox(width: 10, height: 10));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
rebuild(() => node = second);
|
||||
await tester.pump();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
|
||||
expect(first.disposeCalls, 0);
|
||||
expect(second.disposeCalls, 0);
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
});
|
||||
|
||||
testWidgets('FocusableActionBar never disposes caller-owned nodes across swaps', (tester) async {
|
||||
final first = _TrackingFocusNode();
|
||||
final second = _TrackingFocusNode();
|
||||
late StateSetter rebuild;
|
||||
var node = first;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
rebuild = setState;
|
||||
return FocusableActionBar(
|
||||
actions: [FocusableAction(focusNode: node, onPressed: () {})],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
rebuild(() => node = second);
|
||||
await tester.pump();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
|
||||
expect(first.disposeCalls, 0);
|
||||
expect(second.disposeCalls, 0);
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
});
|
||||
|
||||
testWidgets('FocusableTextField never disposes caller-owned nodes across swaps', (tester) async {
|
||||
final first = _TrackingFocusNode();
|
||||
final second = _TrackingFocusNode();
|
||||
final controller = TextEditingController();
|
||||
late StateSetter rebuild;
|
||||
var node = first;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
rebuild = setState;
|
||||
return FocusableTextField(controller: controller, focusNode: node, enableTvKeyboard: false);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
rebuild(() => node = second);
|
||||
await tester.pump();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
|
||||
expect(first.disposeCalls, 0);
|
||||
expect(second.disposeCalls, 0);
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class _TrackingFocusNode extends FocusNode {
|
||||
int disposeCalls = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeCalls++;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/hub_vertical_navigation.dart';
|
||||
|
||||
void main() {
|
||||
test('empty hub lists do not consume navigation', () {
|
||||
expect(
|
||||
navigateVerticalHubRows(
|
||||
hubCount: 0,
|
||||
hubIndex: 0,
|
||||
isUp: true,
|
||||
requestFocus: (_) => fail('must not request focus'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('valid movement requests the adjacent row and consumes', () {
|
||||
int? requested;
|
||||
|
||||
final handled = navigateVerticalHubRows(
|
||||
hubCount: 3,
|
||||
hubIndex: 1,
|
||||
isUp: false,
|
||||
requestFocus: (index) => requested = index,
|
||||
);
|
||||
|
||||
expect(handled, isTrue);
|
||||
expect(requested, 2);
|
||||
});
|
||||
|
||||
test('top boundary can propagate to the row callback', () {
|
||||
expect(
|
||||
navigateVerticalHubRows(
|
||||
hubCount: 2,
|
||||
hubIndex: 0,
|
||||
isUp: true,
|
||||
propagateTopBoundary: true,
|
||||
requestFocus: (_) => fail('must not request focus'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit top handoff consumes navigation', () {
|
||||
var handoffs = 0;
|
||||
|
||||
final handled = navigateVerticalHubRows(
|
||||
hubCount: 2,
|
||||
hubIndex: 0,
|
||||
isUp: true,
|
||||
onTopBoundary: () => handoffs++,
|
||||
requestFocus: (_) => fail('must not request focus'),
|
||||
);
|
||||
|
||||
expect(handled, isTrue);
|
||||
expect(handoffs, 1);
|
||||
});
|
||||
|
||||
test('bottom boundary invokes its handoff and always consumes', () {
|
||||
var handoffs = 0;
|
||||
|
||||
final handled = navigateVerticalHubRows(
|
||||
hubCount: 2,
|
||||
hubIndex: 1,
|
||||
isUp: false,
|
||||
onBottomBoundary: () => handoffs++,
|
||||
requestFocus: (_) => fail('must not request focus'),
|
||||
);
|
||||
|
||||
expect(handled, isTrue);
|
||||
expect(handoffs, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/playback_timeline.dart';
|
||||
|
||||
void main() {
|
||||
test('seek detection uses one shared strict threshold', () {
|
||||
final timeline = PlaybackTimeline();
|
||||
|
||||
expect(timeline.updatePosition(const Duration(seconds: 5)), isFalse);
|
||||
expect(timeline.updatePosition(const Duration(seconds: 11)), isTrue);
|
||||
expect(timeline.position, const Duration(seconds: 11));
|
||||
});
|
||||
|
||||
test('watched threshold accepts the exact boundary', () {
|
||||
final timeline = PlaybackTimeline(duration: const Duration(seconds: 100), watchedThreshold: 0.9);
|
||||
|
||||
timeline.updatePosition(const Duration(seconds: 90));
|
||||
|
||||
expect(timeline.watchedThresholdReached, isTrue);
|
||||
});
|
||||
|
||||
test('unknown duration is not watched and reports zero progress', () {
|
||||
final timeline = PlaybackTimeline(position: const Duration(seconds: 30));
|
||||
|
||||
expect(timeline.updateDuration(Duration.zero), isFalse);
|
||||
expect(timeline.watchedThresholdReached, isFalse);
|
||||
expect(timeline.progressPercent, 0);
|
||||
});
|
||||
|
||||
test('progress is clamped and reset clears prior playback timing', () {
|
||||
final timeline = PlaybackTimeline(position: const Duration(seconds: 120), duration: const Duration(seconds: 100));
|
||||
|
||||
expect(timeline.progressPercent, 100);
|
||||
|
||||
timeline.reset(watchedThreshold: 0.8);
|
||||
|
||||
expect(timeline.position, Duration.zero);
|
||||
expect(timeline.duration, isNull);
|
||||
expect(timeline.watchedThreshold, 0.8);
|
||||
expect(timeline.watchedThresholdReached, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -108,6 +108,58 @@ void main() {
|
||||
expect(hooked, [(0, 5)]);
|
||||
});
|
||||
|
||||
testWidgets('loadInitialPaginatedItems applies reset, data, and success callback', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
var reset = false;
|
||||
List<MediaItem>? applied;
|
||||
(int, int)? counts;
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
fetcher: (start, size, abort) async => _result(start: start, size: size, totalSize: 7),
|
||||
),
|
||||
);
|
||||
|
||||
final succeeded = await state.loadInitialPaginatedItems(
|
||||
pageSize: 3,
|
||||
resetViewState: () => reset = true,
|
||||
applyLoadedItems: (items) => applied = items,
|
||||
applyError: (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(counts, (3, 7));
|
||||
});
|
||||
|
||||
testWidgets('loadInitialPaginatedItems applies one error transaction', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
Object? appliedError;
|
||||
Object? loggedError;
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
fetcher: (start, size, abort) async => throw StateError('failed page'),
|
||||
),
|
||||
);
|
||||
|
||||
final succeeded = await state.loadInitialPaginatedItems(
|
||||
pageSize: 3,
|
||||
resetViewState: () {},
|
||||
applyLoadedItems: (_) => fail('items must not be applied'),
|
||||
applyError: (error, stackTrace) => appliedError = error,
|
||||
onError: (error, stackTrace) => loggedError = error,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(succeeded, isFalse);
|
||||
expect(appliedError, isA<StateError>());
|
||||
expect(loggedError, same(appliedError));
|
||||
});
|
||||
|
||||
testWidgets('totalSize == 0 means no more pages — ensureRangeLoaded is a no-op', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/paged_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
@@ -159,12 +160,7 @@ class _PagedHubClient implements MediaServerClient {
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
requestedStarts.add(start);
|
||||
final offset = start ?? 0;
|
||||
return LibraryPage(
|
||||
items: items.skip(offset).take(size ?? items.length).toList(growable: false),
|
||||
totalCount: items.length,
|
||||
offset: offset,
|
||||
);
|
||||
return fakeLibraryPage(items, start: start, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -23,6 +23,8 @@ import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/providers/watch_state_store.dart';
|
||||
import 'package:plezy/screens/media_detail_screen.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
|
||||
import '../test_helpers/paged_fakes.dart';
|
||||
import 'package:plezy/services/download_manager_service.dart';
|
||||
import 'package:plezy/services/download_storage_service.dart';
|
||||
import 'package:plezy/services/jellyfin_api_cache.dart';
|
||||
@@ -1024,11 +1026,7 @@ class _FakeMediaServerClient implements MediaServerClient {
|
||||
if (error != null) throw error;
|
||||
final all =
|
||||
await (childrenPageFutures[parentId] ?? Future.value(childrenByParent[parentId] ?? const <MediaItem>[]));
|
||||
final offset = start ?? 0;
|
||||
final limit = size ?? all.length;
|
||||
final end = (offset + limit).clamp(0, all.length).toInt();
|
||||
final items = offset >= all.length ? const <MediaItem>[] : all.sublist(offset, end);
|
||||
return LibraryPage(items: items, totalCount: all.length, offset: offset);
|
||||
return fakeLibraryPage(all, start: start, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -23,6 +23,8 @@ import 'package:plezy/services/playlist_items_loader.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
|
||||
import '../test_helpers/paged_fakes.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -271,8 +273,7 @@ class _PagedPlaylistClient implements MediaServerClient {
|
||||
_hasFailed = true;
|
||||
throw StateError('temporary continuation failure');
|
||||
}
|
||||
final limit = size ?? items.length;
|
||||
return LibraryPage(items: items.skip(offset).take(limit).toList(), totalCount: items.length, offset: offset);
|
||||
return fakeLibraryPage(items, start: start, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -20,14 +20,10 @@ import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
JellyfinConnection _conn() => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
JellyfinConnection _conn() => testJellyfinConnection(
|
||||
userName: 'edde',
|
||||
accessToken: 'tok-abc',
|
||||
deviceId: 'dev-xyz',
|
||||
|
||||
@@ -17,46 +17,10 @@ import 'package:plezy/services/download_artwork_service.dart';
|
||||
import 'package:plezy/services/download_storage_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.root);
|
||||
|
||||
final Directory root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => _ensure('documents');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => _ensure('support');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationCachePath() async => _ensure('cache');
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => _ensure('temp');
|
||||
|
||||
String _ensure(String name) {
|
||||
final path = p.join(root.path, name);
|
||||
Directory(path).createSync(recursive: true);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeHttpClient extends http.BaseClient {
|
||||
_FakeHttpClient(this.statusCode, this.body);
|
||||
|
||||
final int statusCode;
|
||||
final List<int> body;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
|
||||
}
|
||||
}
|
||||
|
||||
class _DelayedCountingHttpClient extends http.BaseClient {
|
||||
_DelayedCountingHttpClient(this.body);
|
||||
|
||||
@@ -80,7 +44,7 @@ void main() {
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
tmpRoot = await Directory.systemTemp.createTemp('download_artwork_service_test_');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmpRoot);
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -114,7 +78,7 @@ void main() {
|
||||
await storage.initialize(settings);
|
||||
final service = DownloadArtworkService(
|
||||
storageService: storage,
|
||||
http: MediaServerHttpClient(client: _FakeHttpClient(200, utf8.encode('image'))),
|
||||
http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('image'))),
|
||||
);
|
||||
|
||||
const tokenized = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret';
|
||||
@@ -125,7 +89,7 @@ void main() {
|
||||
|
||||
test('downloadFile rejects non-success responses without leaving final files', () async {
|
||||
final file = File(p.join(tmpRoot.path, 'art.jpg'));
|
||||
final httpClient = MediaServerHttpClient(client: _FakeHttpClient(404, utf8.encode('not found')));
|
||||
final httpClient = MediaServerHttpClient(client: FakeHttpClient(404, utf8.encode('not found')));
|
||||
|
||||
await expectLater(
|
||||
httpClient.downloadFile('https://example.test/art.jpg', file.path),
|
||||
@@ -143,7 +107,7 @@ void main() {
|
||||
final body = utf8.encode('valid image bytes');
|
||||
final service = DownloadArtworkService(
|
||||
storageService: storage,
|
||||
http: MediaServerHttpClient(client: _FakeHttpClient(200, body)),
|
||||
http: MediaServerHttpClient(client: FakeHttpClient(200, body)),
|
||||
);
|
||||
|
||||
const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret';
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
@@ -26,9 +25,9 @@ import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/saf_storage_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:saf_util/saf_util_platform_interface.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
@@ -190,7 +189,7 @@ void main() {
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmpRoot);
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
addTearDown(() async {
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
@@ -263,7 +262,7 @@ void main() {
|
||||
database: db,
|
||||
storageService: storage,
|
||||
clientResolver: (serverId, {clientScopeId}) => client,
|
||||
http: MediaServerHttpClient(client: _FakeHttpClient(200, utf8.encode('image bytes'))),
|
||||
http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('image bytes'))),
|
||||
);
|
||||
|
||||
await manager.repairMissingArtworkForDownloads();
|
||||
@@ -284,7 +283,7 @@ void main() {
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_delete_test_');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmpRoot);
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
addTearDown(() async {
|
||||
DownloadStorageService.resetForTesting();
|
||||
SettingsService.resetForTesting();
|
||||
@@ -551,7 +550,7 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmpRoot);
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
|
||||
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
||||
if (!saf) {
|
||||
@@ -664,7 +663,7 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind,
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmpRoot);
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
|
||||
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
||||
if (!saf) await storage.initialize(await SettingsService.getInstance());
|
||||
@@ -939,42 +938,6 @@ class _ScopedJellyfinClient implements MediaServerClient, ScopedMediaServerClien
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.root);
|
||||
|
||||
final Directory root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => _ensure('documents');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => _ensure('support');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationCachePath() async => _ensure('cache');
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => _ensure('temp');
|
||||
|
||||
String _ensure(String name) {
|
||||
final path = p.join(root.path, name);
|
||||
Directory(path).createSync(recursive: true);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeHttpClient extends http.BaseClient {
|
||||
_FakeHttpClient(this.statusCode, this.body);
|
||||
|
||||
final int statusCode;
|
||||
final List<int> body;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtworkRepairClient implements MediaServerClient {
|
||||
_ArtworkRepairClient({required this.serverId, required this.items});
|
||||
|
||||
|
||||
@@ -9,42 +9,10 @@ import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/services/download_storage_service.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
/// In-test fake PathProviderPlatform that points all directories at a real
|
||||
/// on-disk temp folder. Required because the production service calls
|
||||
/// [getApplicationDocumentsDirectory] / [getApplicationSupportDirectory] —
|
||||
/// both of which fail outside an app context unless the platform interface
|
||||
/// is mocked.
|
||||
class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.root);
|
||||
|
||||
final Directory root;
|
||||
String get _docs => p.join(root.path, 'documents');
|
||||
String get _support => p.join(root.path, 'support');
|
||||
String get _cache => p.join(root.path, 'cache');
|
||||
String get _temp => p.join(root.path, 'temp');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => _ensure(_docs);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => _ensure(_support);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationCachePath() async => _ensure(_cache);
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => _ensure(_temp);
|
||||
|
||||
String _ensure(String dir) {
|
||||
Directory(dir).createSync(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory tmpRoot;
|
||||
|
||||
@@ -53,7 +21,7 @@ void main() {
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
tmpRoot = await Directory.systemTemp.createTemp('dss_test_');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmpRoot);
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'package:plezy/services/jellyfin_endpoint_discovery.dart';
|
||||
import 'package:plezy/utils/log_redaction_manager.dart';
|
||||
import 'package:plezy/utils/media_server_timeouts.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
/// Helpers for stubbing http responses keyed by request path.
|
||||
typedef _Handler = FutureOr<http.Response> Function(http.BaseRequest req);
|
||||
|
||||
@@ -20,12 +22,7 @@ http.Response _bareOk(String body) => http.Response(body, 200, headers: {'conten
|
||||
http.Response _status(int code, [Object? json]) =>
|
||||
http.Response(json == null ? '' : jsonEncode(json), code, headers: {'content-type': 'application/json'});
|
||||
|
||||
JellyfinConnection _existingConn({String accessToken = 'tok-old'}) => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
JellyfinConnection _existingConn({String accessToken = 'tok-old'}) => testJellyfinConnection(
|
||||
userName: 'edde',
|
||||
accessToken: accessToken,
|
||||
deviceId: 'dev-xyz',
|
||||
|
||||
@@ -11,20 +11,18 @@ import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/services/jellyfin_api_cache.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
|
||||
JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List<String>? baseUrls}) => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List<String>? baseUrls}) => testJellyfinConnection(
|
||||
baseUrl: baseUrl,
|
||||
baseUrls: baseUrls,
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
userName: 'edde',
|
||||
accessToken: 'tok-abc',
|
||||
deviceId: 'dev-xyz',
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
JellyfinClient _withMock(MockClient mock) => JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
JellyfinClient _withMock(MockClient mock) => testJellyfinClient(connection: _conn(), httpClient: mock);
|
||||
|
||||
/// Failure-path coverage for the Jellyfin HTTP layer.
|
||||
///
|
||||
|
||||
@@ -14,13 +14,12 @@ import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/utils/device_identity.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/paged_fakes.dart';
|
||||
|
||||
JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) =>
|
||||
JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
testJellyfinConnection(
|
||||
baseUrl: baseUrl,
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
userName: 'edde',
|
||||
accessToken: accessToken,
|
||||
deviceId: 'dev-xyz',
|
||||
@@ -3282,7 +3281,10 @@ void main() {
|
||||
final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
|
||||
final limit = int.parse(req.url.queryParameters['Limit'] ?? '2');
|
||||
return http.Response(
|
||||
jsonEncode({'Items': allItems.skip(start).take(limit).toList(), 'TotalRecordCount': allItems.length}),
|
||||
jsonEncode({
|
||||
'Items': sliceFakePage(allItems, start: start, size: limit),
|
||||
'TotalRecordCount': allItems.length,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
|
||||
@@ -7,25 +7,23 @@ import 'package:plezy/models/livetv_channel.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
JellyfinConnection _conn({required String userId}) => JellyfinConnection(
|
||||
id: 'srv-shared/$userId',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Shared JF',
|
||||
serverMachineId: 'srv-shared',
|
||||
JellyfinConnection _conn({required String userId}) => testJellyfinConnection(
|
||||
machineId: 'srv-shared',
|
||||
userId: userId,
|
||||
serverName: 'Shared JF',
|
||||
userName: 'user-$userId',
|
||||
accessToken: 'tok-$userId',
|
||||
deviceId: 'dev-$userId',
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
JellyfinClient _client(JellyfinConnection conn) => JellyfinClient.forTesting(
|
||||
JellyfinClient _client(JellyfinConnection conn) => testJellyfinClient(
|
||||
connection: conn,
|
||||
// Favorites read path is local-only; an http stub that always 500s is
|
||||
// fine since fetchFavoriteChannels never hits it.
|
||||
httpClient: MockClient((_) async => throw StateError('no HTTP expected')),
|
||||
// Favorites read path is local-only; any HTTP call is a test failure.
|
||||
handler: (_) async => throw StateError('no HTTP expected'),
|
||||
);
|
||||
|
||||
String _favKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.id}';
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/jellyfin_mappers.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
const _serverId = 'jf-machine-1';
|
||||
|
||||
/// Captured (trimmed) from a live Jellyfin 10.11 server — an `Audio` row
|
||||
@@ -65,12 +67,7 @@ Map<String, dynamic> _albumJson() => {
|
||||
'MediaType': 'Unknown',
|
||||
};
|
||||
|
||||
JellyfinConnection _conn() => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
JellyfinConnection _conn() => testJellyfinConnection(
|
||||
userName: 'edde',
|
||||
accessToken: 'tok-abc',
|
||||
deviceId: 'dev-xyz',
|
||||
|
||||
@@ -10,12 +10,9 @@ import 'package:plezy/services/jellyfin_api_cache.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
|
||||
JellyfinConnection _conn() => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
JellyfinConnection _conn() => testJellyfinConnection(
|
||||
userName: 'edde',
|
||||
accessToken: 'tok-abc',
|
||||
deviceId: 'dev-xyz',
|
||||
@@ -44,10 +41,10 @@ void main() {
|
||||
});
|
||||
|
||||
JellyfinClient buildClient(String body) {
|
||||
final mock = MockClient((req) async {
|
||||
return http.Response(body, 200, headers: {'content-type': 'application/json'});
|
||||
});
|
||||
return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
return testJellyfinClient(
|
||||
connection: _conn(),
|
||||
handler: (_) async => http.Response(body, 200, headers: {'content-type': 'application/json'}),
|
||||
);
|
||||
}
|
||||
|
||||
group('JellyfinClient.fetchPlaybackBundle', () {
|
||||
|
||||
@@ -13,6 +13,8 @@ import 'package:plezy/services/media_list_playback_launcher.dart';
|
||||
import 'package:plezy/services/playlist_items_loader.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
import '../test_helpers/paged_fakes.dart';
|
||||
|
||||
/// Recording fake that satisfies [JellyfinClient] via `implements` +
|
||||
/// `noSuchMethod`. The launcher only needs the
|
||||
/// [MediaServerClient.fetchPlayableDescendants] /
|
||||
@@ -62,17 +64,9 @@ class _RecordingJellyfinClient implements JellyfinClient {
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
|
||||
final offset = start ?? 0;
|
||||
final limit = size ?? 100;
|
||||
final limit = size ?? fakeMediaPageSize;
|
||||
fetchPlaylistItemsCalls.add((id: id, offset: offset, limit: limit));
|
||||
if (offset >= playlistItemsResponse.length) {
|
||||
return LibraryPage<MediaItem>(items: const [], totalCount: playlistItemsResponse.length, offset: offset);
|
||||
}
|
||||
final end = (offset + limit).clamp(0, playlistItemsResponse.length);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: playlistItemsResponse.sublist(offset, end),
|
||||
totalCount: playlistItemsResponse.length,
|
||||
offset: offset,
|
||||
);
|
||||
return fakeLibraryPage(playlistItemsResponse, start: start, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -10,12 +10,9 @@ import 'package:plezy/services/jellyfin_trickplay_service.dart';
|
||||
import 'package:plezy/services/scrub_preview_source.dart';
|
||||
import 'package:plezy/utils/device_identity.dart';
|
||||
|
||||
JellyfinConnection _conn() => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
JellyfinConnection _conn() => testJellyfinConnection(
|
||||
userName: 'edde',
|
||||
accessToken: 'tok-abc',
|
||||
deviceId: 'dev-xyz',
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
@@ -16,6 +15,8 @@ import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
@@ -26,29 +27,19 @@ void main() {
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
PlexClient plexClient(http.Client httpClient) => PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'plex-token',
|
||||
clientIdentifier: 'plex-device',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
machineIdentifier: 'plex-machine',
|
||||
),
|
||||
PlexClient plexClient(http.Client httpClient) => testPlexClient(
|
||||
config: testPlexConfig(token: 'plex-token', clientIdentifier: 'plex-device', machineIdentifier: 'plex-machine'),
|
||||
serverId: ServerId('plex-machine'),
|
||||
httpClient: httpClient,
|
||||
);
|
||||
|
||||
JellyfinClient jellyfinClient(http.Client httpClient) => JellyfinClient.forTesting(
|
||||
connection: JellyfinConnection(
|
||||
id: 'jellyfin-machine/user-1',
|
||||
JellyfinClient jellyfinClient(http.Client httpClient) => testJellyfinClient(
|
||||
connection: testJellyfinConnection(
|
||||
machineId: 'jellyfin-machine',
|
||||
userId: 'user-1',
|
||||
baseUrl: 'https://jellyfin.example.com',
|
||||
serverName: 'Jellyfin',
|
||||
serverMachineId: 'jellyfin-machine',
|
||||
userId: 'user-1',
|
||||
userName: 'User',
|
||||
accessToken: 'jellyfin-token',
|
||||
deviceId: 'device-1',
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
),
|
||||
httpClient: httpClient,
|
||||
|
||||
@@ -14,24 +14,20 @@ import 'package:plezy/services/plex_client.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection(
|
||||
id: 'jf-machine/$userId',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Shared JF',
|
||||
serverMachineId: 'jf-machine',
|
||||
JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
|
||||
machineId: 'jf-machine',
|
||||
userId: userId,
|
||||
serverName: 'Shared JF',
|
||||
userName: userId,
|
||||
accessToken: 'token-$userId',
|
||||
deviceId: 'device',
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
JellyfinClient _jellyfinClient(String userId) => JellyfinClient.forTesting(
|
||||
connection: _jellyfinConnection(userId),
|
||||
httpClient: MockClient((_) async => http.Response('{}', 200)),
|
||||
);
|
||||
JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: _jellyfinConnection(userId));
|
||||
|
||||
// NOTE on coverage scope:
|
||||
// [MultiServerManager.addServer] / `connectToAllServers` / `_createClientForServer`
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:plezy/services/offline_mode_source.dart';
|
||||
import 'package:plezy/services/offline_watch_sync_service.dart';
|
||||
import 'package:plezy/utils/watch_state_notifier.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
// NOTE on coverage scope:
|
||||
@@ -154,12 +155,10 @@ class _ScopedRecordingMediaClient extends _RecordingMediaClient implements Scope
|
||||
return (svc: svc, db: db, mgr: mgr);
|
||||
}
|
||||
|
||||
JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection(
|
||||
id: 'jf-machine/$userId',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Shared JF',
|
||||
serverMachineId: 'jf-machine',
|
||||
JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
|
||||
machineId: 'jf-machine',
|
||||
userId: userId,
|
||||
serverName: 'Shared JF',
|
||||
userName: userId,
|
||||
accessToken: 'token-$userId',
|
||||
deviceId: 'device',
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'dart:io';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
@@ -21,37 +20,10 @@ import 'package:plezy/services/playback_initialization_service.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_mappers.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.root);
|
||||
|
||||
final Directory root;
|
||||
String get _docs => p.join(root.path, 'documents');
|
||||
String get _support => p.join(root.path, 'support');
|
||||
String get _cache => p.join(root.path, 'cache');
|
||||
String get _temp => p.join(root.path, 'temp');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => _ensure(_docs);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => _ensure(_support);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationCachePath() async => _ensure(_cache);
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => _ensure(_temp);
|
||||
|
||||
String _ensure(String dir) {
|
||||
Directory(dir).createSync(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -63,7 +35,7 @@ void main() {
|
||||
SettingsService.resetForTesting();
|
||||
DownloadStorageService.resetForTesting();
|
||||
tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_');
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmpRoot);
|
||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
JellyfinApiCache.initialize(db);
|
||||
|
||||
@@ -3,14 +3,14 @@ import 'dart:convert';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
@@ -21,19 +21,8 @@ void main() {
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: ServerId('server-id'),
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
||||
|
||||
test('void mutations surface non-success responses', () async {
|
||||
final client = makeClient((_) async => http.Response('rejected', 500));
|
||||
|
||||
@@ -4,13 +4,13 @@ import 'package:plezy/media/ids.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
@@ -23,19 +23,8 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: ServerId('server-id'),
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
||||
|
||||
test('filters and sorts use dedicated Plex endpoints', () async {
|
||||
final requests = <Uri>[];
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/audio_quality_preset.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
@@ -21,19 +21,8 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: ServerId('server-id'),
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
||||
|
||||
test('music transcode params cap bitrate and carry the musicProfile target', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
|
||||
@@ -4,19 +4,19 @@ import 'package:plezy/media/ids.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
@@ -29,19 +29,8 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: ServerId('server-id'),
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
||||
|
||||
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return MediaSourceInfo(
|
||||
|
||||
@@ -4,12 +4,12 @@ import 'package:plezy/media/ids.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
|
||||
|
||||
void main() {
|
||||
@@ -24,20 +24,8 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: 'test',
|
||||
),
|
||||
serverId: ServerId('plex-1'),
|
||||
serverName: 'Plex',
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||
testPlexClient(serverId: ServerId('plex-1'), serverName: 'Plex', handler: handler);
|
||||
|
||||
test('search defaults to 100 movie, TV, and music candidates', () async {
|
||||
final captured = <Uri>[];
|
||||
|
||||
@@ -3,18 +3,18 @@ import 'dart:convert';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
/// Regression coverage for the Plex transcode reporting bug: while
|
||||
/// transcoding, the `/:/timeline` reports must carry the playback's
|
||||
/// `X-Plex-Session-Identifier` so the server correlates the timeline with the
|
||||
@@ -32,19 +32,8 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: ServerId('server-id'),
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
||||
|
||||
/// Captures every request and answers `/:/timeline` with 200 so
|
||||
/// [PlexClient.updateProgress]'s `throwIfHttpError` is satisfied.
|
||||
|
||||
@@ -5,12 +5,12 @@ import 'dart:io';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
@@ -77,23 +77,16 @@ void main() {
|
||||
}
|
||||
|
||||
PlexClient _makeClient(Map<String, dynamic> rootContainer) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: 'test',
|
||||
),
|
||||
return testPlexClient(
|
||||
serverId: ServerId('server-id'),
|
||||
httpClient: MockClient((request) async {
|
||||
handler: (request) async {
|
||||
expect(request.url.path, '/');
|
||||
return http.Response(
|
||||
jsonEncode({'MediaContainer': rootContainer}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,13 @@ import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/sync_rule_executor.dart';
|
||||
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection(
|
||||
id: 'jf-machine/$userId',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Shared JF',
|
||||
serverMachineId: 'jf-machine',
|
||||
JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
|
||||
machineId: 'jf-machine',
|
||||
userId: userId,
|
||||
serverName: 'Shared JF',
|
||||
userName: userId,
|
||||
accessToken: 'token-$userId',
|
||||
deviceId: 'device',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
JellyfinConnection testJellyfinConnection({
|
||||
String machineId = 'srv-1',
|
||||
String userId = 'user-1',
|
||||
String? id,
|
||||
String baseUrl = 'https://jf.example.com',
|
||||
List<String>? baseUrls,
|
||||
String serverName = 'Home',
|
||||
String userName = 'User',
|
||||
String accessToken = 'token',
|
||||
String deviceId = 'device-1',
|
||||
bool isAdministrator = false,
|
||||
ConnectionStatus status = ConnectionStatus.unknown,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
return JellyfinConnection(
|
||||
id: id ?? '$machineId/$userId',
|
||||
baseUrl: baseUrl,
|
||||
baseUrls: baseUrls,
|
||||
serverName: serverName,
|
||||
serverMachineId: machineId,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
accessToken: accessToken,
|
||||
deviceId: deviceId,
|
||||
isAdministrator: isAdministrator,
|
||||
status: status,
|
||||
createdAt: createdAt ?? DateTime.utc(2024),
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
PlexConfig testPlexConfig({
|
||||
String baseUrl = 'https://plex.example.com',
|
||||
String? token = 'token',
|
||||
String clientIdentifier = 'test-client',
|
||||
String product = 'Plezy Test',
|
||||
String version = '1.0.0',
|
||||
String platform = 'Flutter Test',
|
||||
String? device,
|
||||
String? deviceName,
|
||||
bool acceptJson = true,
|
||||
String? machineIdentifier,
|
||||
String? languageCode,
|
||||
}) {
|
||||
return PlexConfig(
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
clientIdentifier: clientIdentifier,
|
||||
product: product,
|
||||
version: version,
|
||||
platform: platform,
|
||||
device: device,
|
||||
deviceName: deviceName,
|
||||
acceptJson: acceptJson,
|
||||
machineIdentifier: machineIdentifier,
|
||||
languageCode: languageCode,
|
||||
);
|
||||
}
|
||||
|
||||
JellyfinClient testJellyfinClient({
|
||||
JellyfinConnection? connection,
|
||||
http.Client? httpClient,
|
||||
Future<http.Response> Function(http.Request request)? handler,
|
||||
void Function()? onAllEndpointsExhausted,
|
||||
}) {
|
||||
assert(httpClient == null || handler == null, 'Provide either httpClient or handler, not both');
|
||||
return JellyfinClient.forTesting(
|
||||
connection: connection ?? testJellyfinConnection(),
|
||||
httpClient: httpClient ?? MockClient(handler ?? _defaultResponse),
|
||||
onAllEndpointsExhausted: onAllEndpointsExhausted,
|
||||
);
|
||||
}
|
||||
|
||||
PlexClient testPlexClient({
|
||||
PlexConfig? config,
|
||||
String baseUrl = 'https://plex.example.com',
|
||||
String? token = 'token',
|
||||
ServerId? serverId,
|
||||
String? serverName = 'Server',
|
||||
http.Client? httpClient,
|
||||
Future<http.Response> Function(http.Request request)? handler,
|
||||
List<String>? prioritizedEndpoints,
|
||||
List<({String identifier, String gridEndpoint})> epgProviders = const [],
|
||||
String? homeHubKey,
|
||||
String? promotedHubKey,
|
||||
String? continueWatchingHubKey,
|
||||
}) {
|
||||
assert(httpClient == null || handler == null, 'Provide either httpClient or handler, not both');
|
||||
return PlexClient.forTesting(
|
||||
config: config ?? testPlexConfig(baseUrl: baseUrl, token: token),
|
||||
serverId: serverId ?? ServerId('server-1'),
|
||||
serverName: serverName,
|
||||
httpClient: httpClient ?? MockClient(handler ?? _defaultResponse),
|
||||
prioritizedEndpoints: prioritizedEndpoints,
|
||||
epgProviders: epgProviders,
|
||||
homeHubKey: homeHubKey,
|
||||
promotedHubKey: promotedHubKey,
|
||||
continueWatchingHubKey: continueWatchingHubKey,
|
||||
);
|
||||
}
|
||||
|
||||
Future<http.Response> _defaultResponse(http.Request request) async {
|
||||
return http.Response('{}', 200, headers: const {'content-type': 'application/json'});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
/// Routes path-provider lookups to isolated directories below [root].
|
||||
class FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
FakePathProvider(this.root);
|
||||
|
||||
final Directory root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => _ensure('documents');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => _ensure('support');
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationCachePath() async => _ensure('cache');
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => _ensure('temp');
|
||||
|
||||
String _ensure(String name) {
|
||||
final path = p.join(root.path, name);
|
||||
Directory(path).createSync(recursive: true);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns one deterministic streamed response for every request.
|
||||
class FakeHttpClient extends http.BaseClient {
|
||||
FakeHttpClient(this.statusCode, this.body);
|
||||
|
||||
final int statusCode;
|
||||
final List<int> body;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
|
||||
/// Default page size used by production media paging paths.
|
||||
const fakeMediaPageSize = 200;
|
||||
|
||||
List<T> sliceFakePage<T>(List<T> allItems, {int? start, int? size, int defaultPageSize = fakeMediaPageSize}) {
|
||||
final offset = (start ?? 0).clamp(0, allItems.length);
|
||||
final requestedSize = (size ?? defaultPageSize).clamp(0, allItems.length - offset);
|
||||
if (requestedSize == 0) return List<T>.empty(growable: false);
|
||||
return allItems.sublist(offset, offset + requestedSize);
|
||||
}
|
||||
|
||||
LibraryPage<T> fakeLibraryPage<T>(List<T> allItems, {int? start, int? size, int defaultPageSize = fakeMediaPageSize}) {
|
||||
final offset = start ?? 0;
|
||||
return LibraryPage<T>(
|
||||
items: sliceFakePage(allItems, start: offset, size: size, defaultPageSize: defaultPageSize),
|
||||
totalCount: allItems.length,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'paged_fakes.dart';
|
||||
|
||||
void main() {
|
||||
test('fakeLibraryPage uses the shared 200-item default', () {
|
||||
final items = List<int>.generate(250, (index) => index);
|
||||
|
||||
final first = fakeLibraryPage(items);
|
||||
final second = fakeLibraryPage(items, start: fakeMediaPageSize);
|
||||
|
||||
expect(first.items, orderedEquals(List<int>.generate(200, (index) => index)));
|
||||
expect(first.totalCount, 250);
|
||||
expect(first.offset, 0);
|
||||
expect(second.items, orderedEquals(List<int>.generate(50, (index) => index + 200)));
|
||||
expect(second.offset, 200);
|
||||
});
|
||||
|
||||
test('fakeLibraryPage honors explicit bounds and empty trailing pages', () {
|
||||
final items = List<int>.generate(10, (index) => index);
|
||||
|
||||
expect(fakeLibraryPage(items, start: 3, size: 4).items, [3, 4, 5, 6]);
|
||||
final trailing = fakeLibraryPage(items, start: 20, size: 4);
|
||||
expect(trailing.items, isEmpty);
|
||||
expect(trailing.totalCount, 10);
|
||||
expect(trailing.offset, 20);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../test_helpers/paged_fakes.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
@@ -67,11 +68,7 @@ class _RecordingClient implements MediaServerClient {
|
||||
Future<LibraryPage<MediaItem>> fetchChildrenPage(String parentId, {int? start, int? size, abort}) async {
|
||||
childrenPageCalls.add((parentId: parentId, start: start, size: size));
|
||||
final all = childrenPageByParent[parentId] ?? const <MediaItem>[];
|
||||
final offset = start ?? 0;
|
||||
final limit = size ?? all.length;
|
||||
final end = (offset + limit).clamp(0, all.length).toInt();
|
||||
final items = offset >= all.length ? const <MediaItem>[] : all.sublist(offset, end);
|
||||
return LibraryPage(items: items, totalCount: all.length, offset: offset);
|
||||
return fakeLibraryPage(all, start: start, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -97,11 +94,7 @@ class _SeasonPagingRecordingClient extends _RecordingClient implements SeasonEpi
|
||||
}) async {
|
||||
seasonEpisodePageCalls.add((seriesId: seriesId, seasonId: seasonId, start: start, size: size));
|
||||
final all = seasonPageBySeason[(seriesId: seriesId, seasonId: seasonId)] ?? const <MediaItem>[];
|
||||
final offset = start ?? 0;
|
||||
final limit = size ?? all.length;
|
||||
final end = (offset + limit).clamp(0, all.length).toInt();
|
||||
final items = offset >= all.length ? const <MediaItem>[] : all.sublist(offset, end);
|
||||
return LibraryPage(items: items, totalCount: all.length, offset: offset);
|
||||
return fakeLibraryPage(all, start: start, size: size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../test_helpers/paged_fakes.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
@@ -432,9 +433,7 @@ class _AudioPlaylistClient implements MediaServerClient {
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
|
||||
final offset = start ?? 0;
|
||||
final limit = size ?? tracks.length;
|
||||
return LibraryPage(items: tracks.skip(offset).take(limit).toList(), totalCount: tracks.length, offset: offset);
|
||||
return fakeLibraryPage(tracks, start: start, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user