chore: clean up code comments
This commit is contained in:
@@ -75,7 +75,6 @@ void main() {
|
||||
// (no network in the test environment).
|
||||
expect(result.accountLabel, isNotEmpty);
|
||||
|
||||
// The migrated row is now in the registry.
|
||||
final stored = await registry.list();
|
||||
expect(stored.length, 1);
|
||||
expect(stored.single, isA<PlexAccountConnection>());
|
||||
|
||||
@@ -3,11 +3,8 @@ import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_browser_dialect.dart';
|
||||
|
||||
/// Backend-agnostic [Connection] sealed-class tests. The
|
||||
/// `connection_registry_test` already covers DB persistence; these focus on
|
||||
/// the model layer's `toConfigJson` / `fromConfigJson` round-trip and the
|
||||
/// derived `kind` / `backend` mappings — the bits the registry treats as a
|
||||
/// black box.
|
||||
/// Backend-agnostic [Connection] model tests pin config round-trips and derived
|
||||
/// kind/backend mappings; registry persistence is covered separately.
|
||||
void main() {
|
||||
group('ConnectionKind', () {
|
||||
test('id round-trips through fromId', () {
|
||||
|
||||
@@ -194,10 +194,8 @@ void main() {
|
||||
test('setDefault flips the flag and clears it on others', () async {
|
||||
await registry.upsert(_jellyfin(id: 'a'));
|
||||
await registry.upsert(_jellyfin(id: 'b'));
|
||||
// First is default by default; explicitly switch to b.
|
||||
await registry.setDefault('b');
|
||||
expect(await _defaultConnectionId(db), 'b');
|
||||
// Switch back to a.
|
||||
await registry.setDefault('a');
|
||||
expect(await _defaultConnectionId(db), 'a');
|
||||
});
|
||||
@@ -205,11 +203,8 @@ void main() {
|
||||
test('remove deletes a row and re-elects a default when needed', () async {
|
||||
await registry.upsert(_jellyfin(id: 'a'));
|
||||
await registry.upsert(_jellyfin(id: 'b'));
|
||||
// a is default (first one in).
|
||||
await registry.remove('a');
|
||||
// b should now be the default.
|
||||
expect(await _defaultConnectionId(db), 'b');
|
||||
// Removing the last clears the default cleanly.
|
||||
await registry.remove('b');
|
||||
expect(await _defaultConnectionId(db), isNull);
|
||||
});
|
||||
@@ -222,11 +217,9 @@ void main() {
|
||||
await registry.upsert(_jellyfin(id: 'b'));
|
||||
expect(await _defaultConnectionId(db), 'a');
|
||||
|
||||
// Re-upsert the default with refreshed credentials.
|
||||
await registry.upsert(_jellyfin(id: 'a', userName: 'refreshed'));
|
||||
expect(await _defaultConnectionId(db), 'a');
|
||||
|
||||
// And re-upserting a non-default row doesn't accidentally promote it.
|
||||
await registry.upsert(_jellyfin(id: 'b', userName: 'refreshed'));
|
||||
expect(await _defaultConnectionId(db), 'a');
|
||||
});
|
||||
|
||||
@@ -61,10 +61,6 @@ class _AppDatabaseTestSuite {
|
||||
}
|
||||
|
||||
void _registerSchemaTests() {
|
||||
// ============================================================
|
||||
// Schema sanity
|
||||
// ============================================================
|
||||
|
||||
group('schema', () {
|
||||
test('all tables are accessible and start empty', () async {
|
||||
expect(await db.select(db.downloadedMedia).get(), isEmpty);
|
||||
@@ -936,13 +932,6 @@ class _AppDatabaseTestSuite {
|
||||
}
|
||||
|
||||
void _registerLegacyDesktopMigrationTests() {
|
||||
// ============================================================
|
||||
// Legacy desktop DB-file relocation (Documents → AppSupport).
|
||||
// Regression coverage for #1022: cross-drive rename (e.g. OneDrive
|
||||
// Documents on X:, AppData on C:) used to throw an uncaught
|
||||
// FileSystemException out of _openConnection and strand the splash.
|
||||
// ============================================================
|
||||
|
||||
group('legacy desktop DB migration', () {
|
||||
late Directory tempDir;
|
||||
|
||||
@@ -1133,10 +1122,6 @@ class _AppDatabaseTestSuite {
|
||||
}
|
||||
|
||||
void _registerApiCacheTests() {
|
||||
// ============================================================
|
||||
// ApiCache schema defaults and constraints
|
||||
// ============================================================
|
||||
|
||||
group('ApiCache', () {
|
||||
test('default pinned=false, custom pinned=true is honored', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k1', data: 'a'));
|
||||
@@ -1159,10 +1144,6 @@ class _AppDatabaseTestSuite {
|
||||
}
|
||||
|
||||
void _registerDownloadedMediaTests() {
|
||||
// ============================================================
|
||||
// DownloadedMedia: persistence, defaults, constraints, and helpers
|
||||
// ============================================================
|
||||
|
||||
group('DownloadedMedia', () {
|
||||
Future<int> insertMovie({
|
||||
String serverId = 'srv1',
|
||||
@@ -1471,10 +1452,6 @@ class _AppDatabaseTestSuite {
|
||||
}
|
||||
|
||||
void _registerOfflineWatchProgressTests() {
|
||||
// ============================================================
|
||||
// OfflineWatchProgress helpers
|
||||
// ============================================================
|
||||
|
||||
group('OfflineWatchProgress', () {
|
||||
Future<int> insertAction({
|
||||
String serverId = 's',
|
||||
@@ -2080,10 +2057,6 @@ class _AppDatabaseTestSuite {
|
||||
}
|
||||
|
||||
void _registerSyncRulesTests() {
|
||||
// ============================================================
|
||||
// Sync Rules helpers
|
||||
// ============================================================
|
||||
|
||||
group('SyncRules', () {
|
||||
test('insertSyncRule + getSyncRules round-trip with defaults', () async {
|
||||
await db.insertSyncRule(
|
||||
|
||||
@@ -333,10 +333,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Download queue + getNextQueueItem
|
||||
// ============================================================
|
||||
|
||||
group('queue', () {
|
||||
test('addToQueue inserts a row with defaults', () async {
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:100');
|
||||
@@ -383,7 +379,6 @@ void main() {
|
||||
});
|
||||
|
||||
test('getNextQueueItem only returns items whose media is queued', () async {
|
||||
// Two items in queue; one's media is still queued, the other is downloading.
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: '1',
|
||||
@@ -404,12 +399,10 @@ void main() {
|
||||
|
||||
final next = await db.getNextQueueItem();
|
||||
expect(next, isNotNull);
|
||||
// Should pick srv:1 since srv:2 is downloading (not queued).
|
||||
expect(next!.mediaGlobalKey, 'srv:1');
|
||||
});
|
||||
|
||||
test('getNextQueueItem orders by priority desc, then addedAt asc', () async {
|
||||
// All have queued status
|
||||
await db.insertDownload(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: '1',
|
||||
@@ -445,7 +438,6 @@ void main() {
|
||||
.insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'srv:3', priority: const Value(5), addedAt: now + 50));
|
||||
|
||||
final next = await db.getNextQueueItem();
|
||||
// priority 5 wins; srv:3 added before srv:2.
|
||||
expect(next!.mediaGlobalKey, 'srv:3');
|
||||
});
|
||||
|
||||
@@ -537,10 +529,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Update helpers
|
||||
// ============================================================
|
||||
|
||||
group('update helpers', () {
|
||||
Future<void> seed({String key = 'srv:100'}) async {
|
||||
await db.insertDownload(
|
||||
@@ -558,7 +546,7 @@ void main() {
|
||||
|
||||
final r = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(r.status, DownloadStatus.downloading.index);
|
||||
expect(r.progress, 0); // untouched
|
||||
expect(r.progress, 0);
|
||||
});
|
||||
|
||||
test('updateDownloadProgress writes progress + bytes', () async {
|
||||
@@ -652,10 +640,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Lookup helpers
|
||||
// ============================================================
|
||||
|
||||
group('lookup helpers', () {
|
||||
Future<void> seedTree() async {
|
||||
await db.insertDownload(
|
||||
@@ -885,10 +869,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Download owners
|
||||
// ============================================================
|
||||
|
||||
group('download owners', () {
|
||||
Future<void> insertProfile(String id) async {
|
||||
await db
|
||||
@@ -964,10 +944,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// deleteDownload — removes from both tables
|
||||
// ============================================================
|
||||
|
||||
group('deleteDownload', () {
|
||||
test('removes the row from downloadedMedia AND its queue entry', () async {
|
||||
await db.insertDownload(
|
||||
@@ -999,7 +975,6 @@ void main() {
|
||||
});
|
||||
|
||||
test('deleteDownload on a missing globalKey is a no-op', () async {
|
||||
// Should not throw.
|
||||
expect(await db.deleteDownload('nope:nope'), isNull);
|
||||
expect(await db.select(db.downloadedMedia).get(), isEmpty);
|
||||
expect(await db.select(db.downloadQueue).get(), isEmpty);
|
||||
|
||||
@@ -224,17 +224,14 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
|
||||
|
||||
// Interior RIGHT moves to the next button.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[1]');
|
||||
|
||||
// RIGHT at the last button is trapped — must NOT escape to 'outside'.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[1]');
|
||||
|
||||
// LEFT back to the first, then LEFT again is trapped.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
|
||||
|
||||
@@ -2,14 +2,9 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_playlist.dart';
|
||||
|
||||
/// Backend-agnostic [MediaPlaylist] tests. Mappers (`plex_mappers_test` /
|
||||
/// `jellyfin_mappers_test`) cover JSON → model translation; this file pins
|
||||
/// the neutral model's surface so a future mapper swap can't silently
|
||||
/// regress its derived getters.
|
||||
///
|
||||
/// Note: [MediaPlaylist] does **not** override `==` / `hashCode`, so this
|
||||
/// file deliberately avoids equality tests that would exercise default
|
||||
/// identity behavior.
|
||||
/// Backend-agnostic [MediaPlaylist] tests pin the neutral model's getters
|
||||
/// separately from mapper coverage. The model uses identity equality, so these
|
||||
/// tests intentionally avoid equality assertions.
|
||||
MediaPlaylist _playlist({
|
||||
String id = 'pl1',
|
||||
MediaBackend backend = MediaBackend.plex,
|
||||
@@ -55,7 +50,6 @@ void main() {
|
||||
final renamed = original.copyWith(title: 'Renamed', smart: true);
|
||||
expect(renamed.title, 'Renamed');
|
||||
expect(renamed.smart, isTrue);
|
||||
// Source untouched — copyWith must be non-mutating.
|
||||
expect(original.title, 'Original');
|
||||
expect(original.smart, isFalse);
|
||||
});
|
||||
@@ -148,7 +142,6 @@ void main() {
|
||||
expect(minimal.serverName, isNull);
|
||||
expect(minimal.displayImagePath, isNull);
|
||||
expect(minimal.displayTitle, 'Min');
|
||||
// Without a serverId, globalKey reduces to the bare id.
|
||||
expect(minimal.globalKey, 'pl');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,12 +178,10 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
// Empty list mirrors the "library has no items" wire response.
|
||||
fetcher: (start, size, abort) async => const LibraryPage<MediaItem>(items: [], totalCount: 0),
|
||||
),
|
||||
);
|
||||
|
||||
// Initial page reports totalSize = 0.
|
||||
await state.loadInitialPage(20);
|
||||
await tester.pump();
|
||||
|
||||
@@ -243,7 +241,6 @@ void main() {
|
||||
state.ensureIndexLoaded(350, pageSize: 200);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The probe records its calls; the second one should target start=200.
|
||||
expect(state.fetchArgs.length, greaterThanOrEqualTo(2));
|
||||
final pageFetch = state.fetchArgs.last;
|
||||
expect(pageFetch.start, 200);
|
||||
@@ -259,7 +256,6 @@ void main() {
|
||||
onState: (s) => state = s,
|
||||
fetcher: (start, size, abort) async {
|
||||
if (start == 0) {
|
||||
// Initial page always succeeds so totalSize > 0.
|
||||
return _result(start: 0, size: size, totalSize: 400);
|
||||
}
|
||||
rangeAttempt++;
|
||||
@@ -267,7 +263,6 @@ void main() {
|
||||
// First range fetch fails — triggers retry path.
|
||||
throw MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'boom');
|
||||
}
|
||||
// Retry fetch succeeds.
|
||||
return _result(start: start, size: size, totalSize: 400);
|
||||
},
|
||||
),
|
||||
@@ -327,7 +322,6 @@ void main() {
|
||||
// we'd see another fetch attempt.
|
||||
await tester.pump(const Duration(milliseconds: 1500));
|
||||
|
||||
// Only the failed fetch happened — no retry on cancellation.
|
||||
expect(state.fetchCalls, beforeFetches + 1);
|
||||
});
|
||||
|
||||
@@ -414,7 +408,6 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
// No initial load — totalSize stays 0.
|
||||
state.removeLoadedItemAndShift(0);
|
||||
expect(state.totalSize, 0);
|
||||
});
|
||||
|
||||
@@ -74,9 +74,7 @@ void main() {
|
||||
|
||||
expect(state.tabCount, 3);
|
||||
expect(state.tabController.length, 3);
|
||||
// Initial tab is 0 by default.
|
||||
expect(state.tabController.index, 0);
|
||||
// Auto-focus suppression flag starts false.
|
||||
expect(state.suppressAutoFocus, isFalse);
|
||||
});
|
||||
|
||||
|
||||
@@ -158,7 +158,6 @@ void main() {
|
||||
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
|
||||
await tester.pump(Duration.zero);
|
||||
|
||||
// No second delivery — subscription cancelled.
|
||||
expect(state.events, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +137,6 @@ void main() {
|
||||
]);
|
||||
|
||||
await player.selectSubtitleTrack(const SubtitleTrack(id: '3', language: 'swe'));
|
||||
// A redundant show is a no-op rather than a replayed selection.
|
||||
await player.setProperty('sub-visibility', 'yes');
|
||||
|
||||
expect(harness.subtitleSelections, ['3']);
|
||||
|
||||
@@ -29,7 +29,6 @@ void main() {
|
||||
});
|
||||
|
||||
test('does not mistake an unrelated number for a status', () {
|
||||
// A bare code with no HTTP context must not reach the fatal dialogs.
|
||||
expect(PlayerError.httpStatusFromLog('Set property: stream-buffer-size="404"'), isNull);
|
||||
expect(PlayerError.httpStatusFromLog('audio/aac 500 kbps'), isNull);
|
||||
// Adjacent digits are not a 3-digit status.
|
||||
|
||||
@@ -31,7 +31,6 @@ void main() {
|
||||
expect(p.hiddenLibraryKeys, contains('lib-1'));
|
||||
expect(notified, 1);
|
||||
|
||||
// Same key again → no-op, no extra notification
|
||||
await p.hideLibrary('lib-1');
|
||||
expect(notified, 1);
|
||||
|
||||
@@ -66,7 +65,6 @@ void main() {
|
||||
expect(p.isLibraryHidden('lib-1'), isFalse);
|
||||
expect(p.isLibraryHidden('lib-2'), isTrue);
|
||||
|
||||
// Unhiding an already-absent key is a no-op
|
||||
await p.unhideLibrary('lib-3');
|
||||
expect(p.hiddenLibraryKeys, equals({'lib-2'}));
|
||||
|
||||
@@ -143,7 +141,6 @@ void main() {
|
||||
final p = HiddenLibrariesProvider();
|
||||
await p.ensureInitialized();
|
||||
p.dispose();
|
||||
// Should not throw, even though notifyListeners after dispose normally does.
|
||||
await p.refresh();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,6 @@ void main() {
|
||||
|
||||
test('liveTvServers getter returns an unmodifiable view', () {
|
||||
final p = MultiServerProvider(manager, aggregation);
|
||||
// Empty by default; mutating through the unmodifiable view must throw.
|
||||
expect(() => p.liveTvServers.clear(), throwsUnsupportedError);
|
||||
p.dispose();
|
||||
});
|
||||
@@ -65,7 +64,6 @@ void main() {
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
|
||||
// Push a status change through the manager's public API.
|
||||
manager.updateServerStatus(ServerId('srv-1'), true);
|
||||
// Give the broadcast stream microtask time to deliver.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
@@ -137,11 +135,9 @@ void main() {
|
||||
expect(notified, 2);
|
||||
expect(p.hasExplicitVisibleServerFilter, isTrue);
|
||||
|
||||
// Idempotent: same membership is a no-op.
|
||||
p.setVisibleServerIds({'b', 'a'});
|
||||
expect(notified, 2);
|
||||
|
||||
// Clearing back to null after a real filter is a state change.
|
||||
p.setVisibleServerIds(null);
|
||||
expect(notified, 3);
|
||||
expect(p.hasExplicitVisibleServerFilter, isFalse);
|
||||
|
||||
@@ -46,7 +46,6 @@ void main() {
|
||||
expect(p.isShaderEnabled, isTrue);
|
||||
expect(notified, 1);
|
||||
|
||||
// Verify persisted via the SettingsService directly.
|
||||
final svc = await SettingsService.getInstance();
|
||||
expect(svc.read(SettingsService.globalShaderPreset), ShaderPreset.nvscalerDefault.id);
|
||||
|
||||
@@ -67,7 +66,6 @@ void main() {
|
||||
expect(p.savedPreset, ShaderPreset.nvscalerDefault);
|
||||
expect(notified, 1);
|
||||
|
||||
// Same id → no notify.
|
||||
p.setCurrentPreset(ShaderPreset.none);
|
||||
expect(notified, 1);
|
||||
|
||||
@@ -267,7 +265,6 @@ void main() {
|
||||
final p = ShaderProvider();
|
||||
await Future.delayed(Duration.zero);
|
||||
p.dispose();
|
||||
// Should not throw — setPreset calls safeNotifyListeners under the hood.
|
||||
await p.setPreset(ShaderPreset.none);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,11 +44,9 @@ void main() {
|
||||
expect(p.themeMode, next);
|
||||
expect(notified, 1);
|
||||
|
||||
// Same value → no notify.
|
||||
await p.setThemeMode(next);
|
||||
expect(notified, 1);
|
||||
|
||||
// Verify persisted via SettingsService.
|
||||
final svc = await settings.SettingsService.getInstance();
|
||||
expect(svc.read(settings.SettingsService.themeMode), next);
|
||||
|
||||
@@ -178,7 +176,6 @@ void main() {
|
||||
final p = ThemeProvider();
|
||||
await Future.delayed(Duration.zero);
|
||||
p.dispose();
|
||||
// Should not throw — reload calls safeNotifyListeners under the hood.
|
||||
await p.reload();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,6 @@ void main() {
|
||||
await tester.pumpWidget(harness.wrap(const AlbumDetailScreen(album: _album)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Header: album title (app bar + header), tappable artist line, metadata.
|
||||
expect(find.text('Test Album'), findsWidgets);
|
||||
expect(find.text('Test Artist'), findsOneWidget);
|
||||
expect(find.textContaining('2001'), findsOneWidget);
|
||||
@@ -50,7 +49,6 @@ void main() {
|
||||
expect(find.text('Track Two'), findsOneWidget);
|
||||
expect(find.text('Track Three'), findsOneWidget);
|
||||
|
||||
// Track numbers restart per disc.
|
||||
expect(find.text('1'), findsNWidgets(2));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
// Header: title + total track count.
|
||||
expect(find.text(t.music.queue), findsOneWidget);
|
||||
expect(find.text(t.music.trackCount(n: 3)), findsOneWidget);
|
||||
|
||||
|
||||
@@ -65,15 +65,8 @@ void main() {
|
||||
_poisonedCacheRegression();
|
||||
}
|
||||
|
||||
/// A repair that quarantines the store and then cannot reopen it must not
|
||||
/// leave the process permanently unable to try again.
|
||||
///
|
||||
/// Before #1732's fix the repaired future was built straight from the cache
|
||||
/// loader, bypassing the self-healing `onError` reset that `sharedCache`
|
||||
/// installs. A reopen failure therefore parked a rejected future in
|
||||
/// `_cacheFuture`, and every later attempt replayed that stale error for the
|
||||
/// rest of the process — with the damaged file already moved aside, so a
|
||||
/// restart would have booted cleanly.
|
||||
/// A failed reopen after repair must reset the cached future so a later attempt
|
||||
/// can retry instead of replaying the stale error (#1732).
|
||||
void _poisonedCacheRegression() {
|
||||
group('repairCorruptStore', () {
|
||||
late Directory root;
|
||||
|
||||
@@ -4,18 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/bif_thumbnail_service.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
// BIF (Roku Base Index Format) is a binary container for video timeline
|
||||
// thumbnails. The service exposes [BifThumbnailService] which downloads + parses
|
||||
// a file (network-bound), but the parser itself is reachable through
|
||||
// [BifThumbnailService.load] when paired with a fake [PlexClient] that returns
|
||||
// hand-crafted bytes.
|
||||
//
|
||||
// What's NOT covered (by design):
|
||||
// - The 50MiB size guard — verifying it would mean producing a 50MiB
|
||||
// `Uint8List`, which is wasteful for unit tests.
|
||||
// - The download-throws path — `BifThumbnailService.load` swallows errors
|
||||
// into a "no thumbnails" state, and the only observable difference between
|
||||
// "download failed" and "valid 0-image BIF" is `isAvailable=false`.
|
||||
// BIF parser coverage uses a fake client with hand-crafted bytes. Size-limit and
|
||||
// download-failure behavior are intentionally left to integration coverage.
|
||||
|
||||
/// Build a minimal valid BIF byte buffer.
|
||||
///
|
||||
@@ -85,10 +75,6 @@ class _FakePlexClient implements PlexClient {
|
||||
}
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// Initial state
|
||||
// ============================================================
|
||||
|
||||
group('initial state', () {
|
||||
test('isAvailable is false before load()', () {
|
||||
final svc = BifThumbnailService();
|
||||
@@ -104,10 +90,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Pure parser (via load + getThumbnail)
|
||||
// ============================================================
|
||||
|
||||
group('valid BIF parsing', () {
|
||||
test('parses a 3-entry BIF with default 1000ms multiplier', () async {
|
||||
final bytes = _buildBif([
|
||||
@@ -169,10 +151,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Malformed input
|
||||
// ============================================================
|
||||
|
||||
group('malformed BIF input', () {
|
||||
test('rejects bytes shorter than the 64-byte header', () async {
|
||||
final svc = BifThumbnailService();
|
||||
@@ -238,10 +216,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Empty / null input
|
||||
// ============================================================
|
||||
|
||||
group('empty input', () {
|
||||
test('null download keeps the service in unavailable state', () async {
|
||||
final svc = BifThumbnailService();
|
||||
@@ -271,10 +245,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Reload + dispose
|
||||
// ============================================================
|
||||
|
||||
group('reload + dispose', () {
|
||||
test('a second load() replaces prior entries', () async {
|
||||
final first = _buildBif([
|
||||
|
||||
@@ -139,11 +139,8 @@ class _GatedHubsClient implements MediaServerClient {
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
/// Smoke tests for the surviving cross-server aggregation surface on
|
||||
/// [DataAggregationService]. Single-server passthroughs were removed in
|
||||
/// favour of `context.tryGetMediaClientForServer(...).<method>()`; what's
|
||||
/// left here is the multi-client fan-out, which is testable without a
|
||||
/// real backend by simply asserting the empty-state behaviour.
|
||||
/// Covers the remaining cross-server [DataAggregationService] fan-out surface;
|
||||
/// single-server calls now go through the per-server client context.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late MultiServerManager manager;
|
||||
|
||||
@@ -51,10 +51,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SAF mode (Android-only). On host (macOS/Linux) it is always false.
|
||||
// ============================================================
|
||||
|
||||
group('SAF mode', () {
|
||||
test('isUsingSaf is false on the host (non-Android)', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -81,10 +77,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Default download directory + custom-path switching
|
||||
// ============================================================
|
||||
|
||||
group('downloads directory resolution', () {
|
||||
test('defaults to <appSupport>/downloads on desktop hosts', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -176,10 +168,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Artwork directory
|
||||
// ============================================================
|
||||
|
||||
group('artwork directory', () {
|
||||
test('initializes alongside support directory by default and caches sync path', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -250,10 +238,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Path resolution helpers (relative <-> absolute)
|
||||
// ============================================================
|
||||
|
||||
group('toRelativePath / toAbsolutePath', () {
|
||||
test('strips a single base-dir prefix to make a path relative', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -399,10 +383,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ensureAbsolutePath / getReadablePath
|
||||
// ============================================================
|
||||
|
||||
group('ensureAbsolutePath', () {
|
||||
test('keeps an existing absolute path that points at a real file', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -502,10 +482,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SAF path-component helpers (no platform calls — pure formatting)
|
||||
// ============================================================
|
||||
|
||||
group('SAF path components & names', () {
|
||||
test('movie components/filename use sanitized "Title (Year)"', () async {
|
||||
final dss = DownloadStorageService.instance;
|
||||
@@ -568,10 +544,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Real on-disk media directory helpers
|
||||
// ============================================================
|
||||
|
||||
group('media directories on disk', () {
|
||||
test('getMediaDirectory creates serverId/ratingKey under downloads', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -622,10 +594,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// DownloadStorageException
|
||||
// ============================================================
|
||||
|
||||
group('DownloadStorageException', () {
|
||||
test('toString embeds message, path, and cause', () {
|
||||
final ex = DownloadStorageException('boom', '/tmp/x', StateError('inner'));
|
||||
@@ -637,10 +605,6 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MediaItem fixtures (only the fields the SUT actually reads)
|
||||
// ============================================================
|
||||
|
||||
MediaItem _movie({required String title, int? year}) {
|
||||
return testMediaItem(
|
||||
id: 'm-${title.hashCode}',
|
||||
|
||||
@@ -513,10 +513,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================
|
||||
// loadAdjacentEpisodes: shuffled same-series queue (#1466)
|
||||
// ===========================================================
|
||||
|
||||
group('loadAdjacentEpisodes with a shuffled same-series queue', () {
|
||||
// Mirrors JellyfinSequentialLauncher.launchShuffledShow: the full series
|
||||
// episode list, locally shuffled, published with contextKey == seriesId.
|
||||
|
||||
@@ -50,13 +50,8 @@ class _AbortAwareClient extends http.BaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure-path coverage for the Jellyfin HTTP layer.
|
||||
///
|
||||
/// The original test suite covered the 200-OK happy paths and a single 404
|
||||
/// (handled inside `fetchItem`). Anything else — auth rejection, server
|
||||
/// errors, malformed JSON — was untested. These cases are the exact shapes
|
||||
/// that surface in the field when a Jellyfin server is mid-update or the
|
||||
/// access token has been revoked, so they're worth pinning.
|
||||
/// Failure-path coverage for Jellyfin HTTP errors, malformed responses, and
|
||||
/// revoked credentials beyond the existing happy-path and 404 tests.
|
||||
void main() {
|
||||
// fetchChildren writes through `JellyfinApiCache.instance` on a
|
||||
// successful 200, so the singleton needs to exist for tests that exercise
|
||||
|
||||
@@ -131,11 +131,8 @@ _initializeJellyfinAudioCarry({int? selectedAudioStreamId, AudioTrack? preferred
|
||||
return (client: client, requests: requests);
|
||||
}
|
||||
|
||||
/// URL-builder smoke tests. We can't unit-test a network round-trip without
|
||||
/// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work:
|
||||
/// query parameters must include the right keys and the auth token. These
|
||||
/// tests pin the contract so the next iteration of the player (Task 8 wiring)
|
||||
/// has something to point at.
|
||||
/// URL-builder smoke tests. Without a live Jellyfin server, pin query keys and
|
||||
/// authentication parameters directly.
|
||||
void main() {
|
||||
// Pin device identity so JellyfinClient.create's MediaBrowser header falls
|
||||
// back to Device="Plezy" instead of resolving the host machine's name.
|
||||
|
||||
@@ -82,7 +82,6 @@ void main() {
|
||||
expect(item.viewOffsetMs, 3000000); // 3000s in ms
|
||||
expect(item.viewCount, 1);
|
||||
|
||||
// Image paths.
|
||||
expect(item.thumbPath, '/Items/abc123/Images/Primary?tag=thumbtag');
|
||||
expect(item.artPath, '/Items/abc123/Images/Backdrop/0?tag=backtag');
|
||||
expect(item.backdropPaths, [
|
||||
@@ -92,7 +91,6 @@ void main() {
|
||||
]);
|
||||
expect(item.clearLogoPath, '/Items/abc123/Images/Logo?tag=logotag');
|
||||
|
||||
// Multi-server fields.
|
||||
expect(item.serverId, _serverId);
|
||||
expect(item.serverName, 'Home');
|
||||
});
|
||||
|
||||
@@ -69,7 +69,6 @@ void main() {
|
||||
expect(eng.channels, 6);
|
||||
expect(eng.selected, isTrue);
|
||||
|
||||
// Non-default audio
|
||||
final jpn = info.audioTracks[1];
|
||||
expect(jpn.id, 2);
|
||||
expect(jpn.languageCode, 'jpn');
|
||||
@@ -105,7 +104,6 @@ void main() {
|
||||
|
||||
expect(info.defaultSubtitleStreamIndex, isNull);
|
||||
expect(info.subtitleTracks.map((track) => track.selected), [false, false]);
|
||||
// The row metadata itself is untouched; only the selection claim is.
|
||||
expect(info.subtitleTracks.first.forced, isTrue);
|
||||
// Audio keeps the container default: something always has to play.
|
||||
expect(info.audioTracks.single.selected, isTrue);
|
||||
|
||||
@@ -86,7 +86,6 @@ void main() {
|
||||
expect(item.parentTitle, 'Live at Testhalle');
|
||||
expect(item.grandparentId, 'a603621309dc866c91b6c5fe10cee64d');
|
||||
expect(item.grandparentTitle, 'The Synth Pops');
|
||||
// Derived music getters.
|
||||
expect(item.trackNumber, 1);
|
||||
expect(item.discNumber, 1);
|
||||
expect(item.albumTitle, 'Live at Testhalle');
|
||||
|
||||
@@ -80,7 +80,6 @@ void main() {
|
||||
final history = await LocalPlaybackHistory.snapshot();
|
||||
expect(history.length, 400);
|
||||
expect(history, contains('srv-1:fresh'));
|
||||
// The oldest entry (value 1) was evicted to make room.
|
||||
expect(history, isNot(contains('srv-1:old-0')));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,18 +104,9 @@ class _LoopbackJellyfinServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage includes status and lifecycle changes, endpoint exhaustion,
|
||||
// in-place and fresh scoped Plex profile binding, endpoint persistence and
|
||||
// promotion ownership, connectivity monitoring/debounce teardown, Jellyfin
|
||||
// reuse/update, and selected registered-client health outcomes.
|
||||
|
||||
void main() {
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
// ============================================================
|
||||
// Initial state
|
||||
// ============================================================
|
||||
|
||||
group('initial state', () {
|
||||
test('a freshly constructed manager has no servers, clients, or status', () {
|
||||
final m = MultiServerManager();
|
||||
@@ -136,10 +127,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// updateServerStatus + status stream
|
||||
// ============================================================
|
||||
|
||||
group('updateServerStatus + statusStream', () {
|
||||
test('emits a snapshot when status flips for a tracked server', () async {
|
||||
final m = MultiServerManager();
|
||||
@@ -1436,10 +1423,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// addJellyfinConnection reuse
|
||||
// ============================================================
|
||||
|
||||
group('addJellyfinConnection reuse', () {
|
||||
// The reuse branch is what keeps a passive rebind (re-adding the same
|
||||
// persisted connection) from tearing down a live client and aborting its
|
||||
@@ -1543,10 +1526,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// removeServer
|
||||
// ============================================================
|
||||
|
||||
group('removeServer', () {
|
||||
test('removes a tracked server\'s status entry and emits a snapshot', () async {
|
||||
final m = MultiServerManager();
|
||||
@@ -1602,10 +1581,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// disconnectAll
|
||||
// ============================================================
|
||||
|
||||
group('disconnectAll', () {
|
||||
test('clears all status and emits an empty snapshot', () async {
|
||||
final m = MultiServerManager();
|
||||
@@ -1642,10 +1617,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// dispose
|
||||
// ============================================================
|
||||
|
||||
group('dispose', () {
|
||||
test('disposing without connectivity monitoring does not throw', () {
|
||||
final m = MultiServerManager();
|
||||
|
||||
@@ -27,13 +27,8 @@ import '../test_helpers/playback_report_fakes.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
// Direct `syncPendingItems` coverage exercises retry retention, Plex/Jellyfin
|
||||
// progress replay, profile interruption, and scoped Jellyfin routing. Direct
|
||||
// `syncWatchStatesFromServer` coverage exercises active-profile and
|
||||
// active-scope routing plus selected watched outcomes. Trigger coalescing and
|
||||
// throttle sequencing inside `_performBidirectionalSync`, direct cache-row and
|
||||
// refresh-callback assertions, and non-default watched-threshold sources remain
|
||||
// outside this suite.
|
||||
// Direct calls cover retry retention, progress replay, profile interruption, and
|
||||
// scoped routing; trigger coalescing and cache/refresh seams remain out of scope.
|
||||
|
||||
/// Minimal [OfflineModeSource] that lets tests flip the offline flag and
|
||||
/// observe `addListener`/`removeListener` traffic via the protected
|
||||
@@ -162,10 +157,6 @@ JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
|
||||
void main() {
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
// ============================================================
|
||||
// Initial state
|
||||
// ============================================================
|
||||
|
||||
group('initial state', () {
|
||||
test('a freshly constructed service is not syncing and has no pending count', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -213,10 +204,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// queueMarkWatched / queueMarkUnwatched
|
||||
// ============================================================
|
||||
|
||||
group('queueMarkWatched / queueMarkUnwatched', () {
|
||||
test('queueMarkWatched persists a "watched" action and bumps pending count', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -466,11 +453,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// queueProgressUpdate (also exercised so we can test the progress branches
|
||||
// of getLocalWatchStatus / getLocalViewOffset).
|
||||
// ============================================================
|
||||
|
||||
group('syncPendingItems profile scoping', () {
|
||||
test('defers entirely when no profile is active', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -604,10 +586,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Superseded queued progress (#1812)
|
||||
// ============================================================
|
||||
|
||||
group('queued progress superseded by a watch-state write', () {
|
||||
MediaItem itemFor(String id) =>
|
||||
testMediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv');
|
||||
@@ -709,10 +687,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// getLocalWatchStatus
|
||||
// ============================================================
|
||||
|
||||
group('getLocalWatchStatus', () {
|
||||
test('returns null when no local action exists', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -764,10 +738,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// getLocalViewOffset
|
||||
// ============================================================
|
||||
|
||||
group('getLocalViewOffset', () {
|
||||
test('returns null when no local action exists', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -825,10 +795,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// getPendingSyncCount
|
||||
// ============================================================
|
||||
|
||||
group('getPendingSyncCount', () {
|
||||
test('counts every queued action (manual + progress)', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -860,10 +826,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// getLocalWatchStatusesBatched
|
||||
// ============================================================
|
||||
|
||||
group('getLocalWatchStatusesBatched', () {
|
||||
test('empty input returns empty map without touching the DB', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -1488,10 +1450,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// clearAll
|
||||
// ============================================================
|
||||
|
||||
group('clearAll', () {
|
||||
test('removes every queued action and notifies listeners', () async {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
@@ -1514,10 +1472,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// startConnectivityMonitoring + dispose
|
||||
// ============================================================
|
||||
|
||||
group('startConnectivityMonitoring + dispose', () {
|
||||
test('attaches a listener to the source', () {
|
||||
final (svc: svc, db: db, mgr: mgr) = _makeService();
|
||||
|
||||
@@ -73,10 +73,6 @@ PlayQueueResponse _queueWith(MediaItem item) {
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// ============================================================
|
||||
// PlayQueueResult sealed hierarchy
|
||||
// ============================================================
|
||||
|
||||
group('PlayQueueResult', () {
|
||||
test('PlayQueueCancelled is a distinct re-exported result', () {
|
||||
const PlayQueueResult result = PlayQueueCancelled();
|
||||
@@ -85,10 +81,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Pre-flight branches that don't touch the network
|
||||
// ============================================================
|
||||
|
||||
group('launchShuffledShow pre-flight guard', () {
|
||||
testWidgets('returns PlayQueueError when metadata is not a show or season', (tester) async {
|
||||
// Build a launcher inside an active Element so its `context.mounted`
|
||||
|
||||
@@ -22,9 +22,8 @@ import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
import '../test_helpers/playback_report_fakes.dart';
|
||||
|
||||
// Periodic behavior is virtualized with fake_async and the tracker's existing
|
||||
// updateInterval seam. Routing, threshold, scrobble, cadence, coalescing,
|
||||
// backoff, resume, and disposal are asserted through observable calls.
|
||||
// fake_async drives periodic routing, threshold, scrobble, coalescing, backoff,
|
||||
// resume, and disposal behavior through observable calls.
|
||||
|
||||
/// Fake Player whose state is mutable from the test.
|
||||
class _FakePlayer implements Player {
|
||||
@@ -330,10 +329,6 @@ MediaItem _meta({
|
||||
void main() {
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
// ============================================================
|
||||
// Constructor assertions
|
||||
// ============================================================
|
||||
|
||||
group('constructor assertions', () {
|
||||
test('offline=true requires offlineWatchService', () {
|
||||
expect(
|
||||
@@ -350,10 +345,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// sendProgress: short-circuit on duration=0
|
||||
// ============================================================
|
||||
|
||||
group('sendProgress: duration guard', () {
|
||||
test('does NOT send progress when duration is zero (player not yet ready)', () async {
|
||||
final client = _FakePlexClient();
|
||||
@@ -443,10 +434,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// sendProgress: online routing
|
||||
// ============================================================
|
||||
|
||||
group('sendProgress: online', () {
|
||||
test('"stopped" awaits the underlying call and reports correct args', () async {
|
||||
final client = _FakePlexClient();
|
||||
@@ -792,10 +779,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Threshold gating + scrobble
|
||||
// ============================================================
|
||||
|
||||
group('threshold gating', () {
|
||||
test('does NOT scrobble when percent < watchedThresholdPercent', () async {
|
||||
// 89% < 90% threshold.
|
||||
@@ -1397,10 +1380,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Offline routing
|
||||
// ============================================================
|
||||
|
||||
group('sendProgress: offline', () {
|
||||
Future<({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr})> makeOfflineService() async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
@@ -1516,10 +1495,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// WatchStateNotifier emission on 'stopped'
|
||||
// ============================================================
|
||||
|
||||
group('WatchStateNotifier event on "stopped"', () {
|
||||
test('emits a progress-update event when stopped past position 0', () async {
|
||||
final client = _FakePlexClient(thresholdPercent: 90);
|
||||
@@ -1837,10 +1812,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// startTracking / stopTracking / dispose lifecycle
|
||||
// ============================================================
|
||||
|
||||
group('lifecycle', () {
|
||||
test('startTracking + stopTracking is a clean no-op for an inactive player', () async {
|
||||
final client = _FakePlexClient();
|
||||
|
||||
@@ -54,10 +54,6 @@ void main() {
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Singleton
|
||||
// ============================================================
|
||||
|
||||
group('singleton', () {
|
||||
test('initialize swaps the underlying database', () async {
|
||||
final newDb = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
@@ -113,10 +109,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// get / put — cache hit and miss
|
||||
// ============================================================
|
||||
|
||||
group('get / put', () {
|
||||
test('miss returns null for an unknown key', () async {
|
||||
expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull);
|
||||
@@ -196,10 +188,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// deleteForServer / deleteForItem / clearAll
|
||||
// ============================================================
|
||||
|
||||
group('deletion', () {
|
||||
test('deleteForServer wipes only the targeted serverId', () async {
|
||||
await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1'));
|
||||
@@ -247,10 +235,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Pinning
|
||||
// ============================================================
|
||||
|
||||
group('pinning', () {
|
||||
test('isPinned defaults to false for a freshly cached item', () async {
|
||||
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer());
|
||||
@@ -316,10 +300,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// getMetadata / getAllPinnedMetadata
|
||||
// ============================================================
|
||||
|
||||
group('metadata extraction', () {
|
||||
test('getMetadata returns null when the key is not cached', () async {
|
||||
expect(await cache.getMetadata(ServerId('srv'), 'missing'), isNull);
|
||||
|
||||
@@ -299,11 +299,9 @@ void main() {
|
||||
expect(item.roles![0].thumbPath, '/library/metadata/role/1/thumb');
|
||||
expect(item.roles![1].thumbPath, isNull);
|
||||
|
||||
// Library identification.
|
||||
expect(item.libraryId, '1');
|
||||
expect(item.libraryTitle, 'Movies');
|
||||
|
||||
// Server-tagging.
|
||||
expect(item.serverId, _serverId);
|
||||
expect(item.serverName, _serverName);
|
||||
});
|
||||
|
||||
@@ -18,10 +18,6 @@ void main() {
|
||||
timer.cancelTimer();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Initial state
|
||||
// ============================================================
|
||||
|
||||
group('initial state', () {
|
||||
test('isActive is false on a fresh / cancelled service', () {
|
||||
expect(timer.isActive, isFalse);
|
||||
@@ -38,10 +34,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// startTimer — bookkeeping
|
||||
// ============================================================
|
||||
|
||||
group('startTimer', () {
|
||||
test('sets isActive, duration, originalDuration, and endTime', () {
|
||||
timer.startTimer(const Duration(minutes: 30), () {});
|
||||
@@ -81,10 +73,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// cancelTimer
|
||||
// ============================================================
|
||||
|
||||
group('cancelTimer', () {
|
||||
test('clears all state and prevents a later prompt', () {
|
||||
fakeAsync((async) {
|
||||
@@ -185,10 +173,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// restartTimer / restartIfNeeded / markNeedsRestart
|
||||
// ============================================================
|
||||
|
||||
group('restartTimer', () {
|
||||
test('restartTimer after cancel is a no-op (originalDuration cleared)', () {
|
||||
timer.startTimer(const Duration(minutes: 1), () {});
|
||||
@@ -247,10 +231,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// extendTimer
|
||||
// ============================================================
|
||||
|
||||
group('extendTimer', () {
|
||||
test('shifts endTime and grows duration by the additional time', () {
|
||||
timer.startTimer(const Duration(minutes: 10), () {});
|
||||
@@ -274,10 +254,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// executeCompletion
|
||||
// ============================================================
|
||||
|
||||
group('executeCompletion', () {
|
||||
test('runs the stored callback and emits onCompleted', () async {
|
||||
var fired = 0;
|
||||
@@ -311,10 +287,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Change notifications
|
||||
// ============================================================
|
||||
|
||||
group('change notifications', () {
|
||||
test('startTimer and cancelTimer each notify listeners at least once', () {
|
||||
var notifications = 0;
|
||||
@@ -348,10 +320,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// armEndOfVideo / notifyVideoCompleted
|
||||
// ============================================================
|
||||
|
||||
group('armEndOfVideo', () {
|
||||
test('sets isActive and isEndOfVideoMode without starting a periodic timer', () {
|
||||
timer.armEndOfVideo(() {});
|
||||
|
||||
@@ -71,10 +71,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Plex token / client identifier (legacy, retained for migration)
|
||||
// ============================================================
|
||||
|
||||
group('PlexToken & ClientIdentifier (legacy migration slots)', () {
|
||||
test('getPlexToken reads the legacy slot', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -113,10 +109,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Server endpoints (per-server URL caching)
|
||||
// ============================================================
|
||||
|
||||
group('ServerEndpoint', () {
|
||||
test('round-trip per server id', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -138,10 +130,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Multi-server slot (legacy, only `getServersListJson` retained for migration)
|
||||
// ============================================================
|
||||
|
||||
group('Servers list (legacy migration slot)', () {
|
||||
test('legacy raw read returns null when nothing is stored', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -178,10 +166,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Hidden libraries (Set<String> persisted as JSON list)
|
||||
// ============================================================
|
||||
|
||||
group('Hidden libraries', () {
|
||||
test('default is empty set', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -229,10 +213,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Library order (List<String>) — scoped to active profile
|
||||
// ============================================================
|
||||
|
||||
group('Library order', () {
|
||||
test('default is null', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -319,10 +299,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Library filters / sort / grouping / tab
|
||||
// ============================================================
|
||||
|
||||
group('Library filters / sort / grouping / tab', () {
|
||||
test('global filters round-trip', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -394,10 +370,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Current user UUID (legacy slot retained for migration)
|
||||
// ============================================================
|
||||
|
||||
group('CurrentUserUUID (legacy migration slot)', () {
|
||||
test('clearCurrentUserUUID wipes the slot', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -410,10 +382,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Plex Home user-scope migration (full profile id → home-user uuid)
|
||||
// ============================================================
|
||||
|
||||
group('migratePlexHomeUserScopes (onInit)', () {
|
||||
const fullId = 'plex-home-plex.e443d57860076fc3-379704d0c6601309';
|
||||
const uuid = '379704d0c6601309';
|
||||
@@ -459,10 +427,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// clearCredentials
|
||||
// ============================================================
|
||||
|
||||
group('clearCredentials', () {
|
||||
test('removes credential keys, plex token, and multi-server data', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -503,10 +467,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// clearLibraryPreferences (user-scoped)
|
||||
// ============================================================
|
||||
|
||||
group('clearLibraryPreferences', () {
|
||||
test('clears scoped library keys for current user only', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
@@ -644,10 +604,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// clearUserData = clearCredentials + clearLibraryPreferences
|
||||
// ============================================================
|
||||
|
||||
group('clearUserData', () {
|
||||
test('combines credentials and library-preferences clear', () async {
|
||||
final s = await StorageService.getInstance();
|
||||
|
||||
@@ -226,10 +226,6 @@ void main() {
|
||||
// could leak across tests — reset to be safe.
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
// ============================================================
|
||||
// External subtitle cache
|
||||
// ============================================================
|
||||
|
||||
group('cacheExternalSubtitles', () {
|
||||
test('round-trips through the lastExternalSubtitles getter', () {
|
||||
final mgr = _make(player: _FakePlayer());
|
||||
@@ -250,10 +246,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// addExternalSubtitles
|
||||
// ============================================================
|
||||
|
||||
group('addExternalSubtitles', () {
|
||||
test('returns immediately on empty input', () async {
|
||||
final player = _FakePlayer();
|
||||
@@ -376,10 +368,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// applyTrackSelectionWhenReady
|
||||
// ============================================================
|
||||
|
||||
group('applyTrackSelectionWhenReady', () {
|
||||
test('waits for player subtitle tracks when Plex metadata advertises subtitles', () async {
|
||||
await SettingsService.getInstance();
|
||||
@@ -1262,10 +1250,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Explicit user selection vs. the pending automatic pass
|
||||
// ============================================================
|
||||
|
||||
group('explicit user selection', () {
|
||||
test('user audio choice survives the advertised-subtitle deadline', () async {
|
||||
await SettingsService.getInstance();
|
||||
@@ -1570,10 +1554,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Track cycling early-return paths
|
||||
// ============================================================
|
||||
|
||||
group('cycleSubtitleTrack', () {
|
||||
test('no-op when no real subtitle tracks exist', () {
|
||||
// Tracks contains only auto/none (filtered out).
|
||||
@@ -1653,10 +1633,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Misc handlers
|
||||
// ============================================================
|
||||
|
||||
group('onPlaybackRestart', () {
|
||||
test('no-op when not waiting for external subs', () {
|
||||
final mgr = _make(player: _FakePlayer());
|
||||
@@ -1723,10 +1699,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// onSubtitleTrackChanged — same-language stream mapping (#1443)
|
||||
// ============================================================
|
||||
|
||||
group('onSubtitleTrackChanged', () {
|
||||
// Reproduces the #1443 MKVToolNix screenshot: the "forced" French subtitle
|
||||
// is NOT flagged forced in the container — it only carries the name
|
||||
@@ -1834,10 +1806,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Lifecycle
|
||||
// ============================================================
|
||||
|
||||
group('dispose', () {
|
||||
test('is idempotent', () {
|
||||
final mgr = _make(player: _FakePlayer());
|
||||
|
||||
@@ -11,39 +11,9 @@ import 'package:plezy/services/subtitle_preference.dart';
|
||||
import 'package:plezy/services/track_selection_service.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
// NOTE on coverage scope:
|
||||
// `TrackSelectionService` is a large pure logic surface with one async
|
||||
// integration point (`selectAndApplyTracks`). We cover:
|
||||
//
|
||||
// - `languageMatches` — direct, base-code, and ISO 639 variation matching.
|
||||
// - `findBestTrackMatch` / `findBestSubtitleMatch` —
|
||||
// id+title+language exact, title+language, language-only, and the
|
||||
// "auto"/"no" filtering rule.
|
||||
// - `findAudioTrackByProfile` — picks the first preferred-language match,
|
||||
// respects autoSelectAudio, falls back across the language list.
|
||||
// - `selectAudioTrack` — full priority cascade:
|
||||
// Priority 1 (preferred from navigation),
|
||||
// Priority 2 (Plex-selected via media info),
|
||||
// Priority 3 (per-media metadata.audioLanguage),
|
||||
// Priority 4 (user profile),
|
||||
// Priority 5 (default / first track),
|
||||
// and the empty-list null return.
|
||||
// - `selectSubtitleTrack` — preferred=off, preferred=tracked,
|
||||
// Plex-selected, Plex-server-explicit-no-subtitles, default fallback,
|
||||
// and the off-by-default branch.
|
||||
//
|
||||
// Top-level subtitle matching helpers are exercised directly for complete,
|
||||
// partial, unique, ambiguous, and container catalogs. Audio helpers are
|
||||
// exercised through `selectAudioTrack` (Priority 2) and their focused
|
||||
// disambiguation tests below.
|
||||
//
|
||||
// What's NOT covered:
|
||||
// - `selectAndApplyTracks` — depends on a real Player + SettingsService
|
||||
// singleton + `player.streams.tracks`. Out of scope for a unit test.
|
||||
|
||||
// ============================================================
|
||||
// Fixtures
|
||||
// ============================================================
|
||||
// Covers the pure language, track-matching, and audio/subtitle priority helpers,
|
||||
// including fallback and ambiguity rules. `selectAndApplyTracks` is excluded
|
||||
// because it requires a real Player and SettingsService singleton.
|
||||
|
||||
MediaItem _meta({MediaBackend backend = MediaBackend.plex, String? audioLanguage, String? subtitleLanguage}) =>
|
||||
testMediaItem(
|
||||
@@ -192,10 +162,6 @@ TrackSelectionService _svc({MediaItem? metadata, MediaServerUserProfile? profile
|
||||
}
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// languageMatches
|
||||
// ============================================================
|
||||
|
||||
group('languageMatches', () {
|
||||
final svc = _svc();
|
||||
|
||||
@@ -228,10 +194,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// findBestTrackMatch (via the audio/subtitle wrappers)
|
||||
// ============================================================
|
||||
|
||||
group('findBestSubtitleMatch', () {
|
||||
final svc = _svc();
|
||||
|
||||
@@ -251,10 +213,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// findAudioTrackByProfile
|
||||
// ============================================================
|
||||
|
||||
group('findAudioTrackByProfile', () {
|
||||
final svc = _svc();
|
||||
|
||||
@@ -292,10 +250,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// selectAudioTrack — the priority cascade
|
||||
// ============================================================
|
||||
|
||||
group('selectAudioTrack', () {
|
||||
test('returns null on empty available tracks', () {
|
||||
expect(_svc().selectAudioTrack(const [], _audio('1', lang: 'eng')), isNull);
|
||||
@@ -462,10 +416,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// selectSubtitleTrack
|
||||
// ============================================================
|
||||
|
||||
group('selectSubtitleTrack', () {
|
||||
test('Priority 1: preferred id="no" forces subtitles off', () {
|
||||
final tracks = [_sub('1', lang: 'eng', isDefault: true)];
|
||||
@@ -1029,13 +979,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// findPlexTrackForMpvSubtitle / findPlexTrackForMpvAudio — same-language
|
||||
// disambiguation (regression for #1443). The player reports null titles for
|
||||
// MKV tracks that carry only a forced flag, so the forced flag (+2) and the
|
||||
// ordinal tiebreaker (+1) must separate two tracks that share a language.
|
||||
// ============================================================
|
||||
|
||||
group('findPlexTrackForMpvSubtitle - forced disambiguation', () {
|
||||
// Disposition-flagged forced track: forced is set in the container, so both
|
||||
// Plex and the player carry forced=true on the forced track.
|
||||
@@ -1102,12 +1045,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Cross-item intent matching (#1716/#1717): language and effective
|
||||
// forced-ness are hard requirements — the intent's class is preserved or
|
||||
// the match declines so the ladder falls to the server's own selection.
|
||||
// ============================================================
|
||||
|
||||
group('findSourceTrackForIntent', () {
|
||||
const forcedIntent = SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass');
|
||||
const fullIntent = SubtitleIntent(language: 'fre', forced: false, title: 'French', codec: 'srt');
|
||||
@@ -1157,12 +1094,6 @@ void main() {
|
||||
expect(findSourceTrackForIntent(intent, [_plexSub(1, languageCode: 'fre')]), isNull);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// #1785 — missing language tags must not turn the carry off when a
|
||||
// unique real title identifies the row; codec parity alone is never
|
||||
// evidence, and ambiguity declines rather than guesses.
|
||||
// ============================================================
|
||||
|
||||
test('title-only intent matches the row with the same title when tags are missing (#1785)', () {
|
||||
const intent = SubtitleIntent(forced: false, title: 'Swedish', codec: 'subrip');
|
||||
final rows = [_plexSub(1, title: 'English', codec: 'subrip'), _plexSub(2, title: 'Swedish', codec: 'subrip')];
|
||||
|
||||
Reference in New Issue
Block a user