refactor(core): consolidate shared app foundations

This commit is contained in:
edde746
2026-07-12 17:31:12 +02:00
parent a481fc1000
commit 97f7508067
59 changed files with 1244 additions and 919 deletions
+104
View File
@@ -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);
});
}
+41
View File
@@ -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(
+2 -6
View File
@@ -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
+3 -5
View File
@@ -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.
///
+8 -6
View File
@@ -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,
+5 -9
View File
@@ -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 -15
View File
@@ -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>[];
+4 -15
View File
@@ -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 -16
View File
@@ -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>[];
+4 -15
View File
@@ -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'},
);
}),
},
);
}
+4 -5
View File
@@ -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'});
}
+44
View File
@@ -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);
}
}
+20
View File
@@ -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,
);
}
+28
View File
@@ -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);
});
}
+3 -10
View File
@@ -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);
}
}
+2 -3
View File
@@ -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