chore: clean up code comments

This commit is contained in:
edde746
2026-08-10 20:28:41 +02:00
parent 5611c6785a
commit 69fadc220d
170 changed files with 324 additions and 1765 deletions
@@ -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>());
+2 -5
View File
@@ -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');
});
-27
View File
@@ -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(
+1 -26
View File
@@ -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);
-3
View File
@@ -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]');
+3 -10
View File
@@ -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);
});
-1
View File
@@ -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);
-3
View File
@@ -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);
});
});
-3
View File
@@ -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));
});
}
-1
View File
@@ -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;
+2 -32
View File
@@ -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
+2 -5
View File
@@ -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.
-2
View File
@@ -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();
-20
View File
@@ -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);
-2
View File
@@ -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(() {});
-44
View File
@@ -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();
-32
View File
@@ -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')];