From 7f8c066062b177d9dc3563da3f55fbf532f6128d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:04:08 +0200 Subject: [PATCH] test: add provider and drift database tests --- lib/database/app_database.dart | 6 + test/database/app_database_test.dart | 566 ++++++++++++++++++ test/database/download_operations_test.dart | 429 +++++++++++++ test/providers/libraries_provider_test.dart | 111 ++++ test/providers/settings_provider_test.dart | 162 +++++ test/providers/shader_provider_test.dart | 151 +++++ test/providers/theme_provider_test.dart | 170 ++++++ test/providers/trackers_provider_test.dart | 176 ++++++ .../trakt_account_provider_test.dart | 110 ++++ 9 files changed, 1881 insertions(+) create mode 100644 test/database/app_database_test.dart create mode 100644 test/database/download_operations_test.dart create mode 100644 test/providers/libraries_provider_test.dart create mode 100644 test/providers/settings_provider_test.dart create mode 100644 test/providers/shader_provider_test.dart create mode 100644 test/providers/theme_provider_test.dart create mode 100644 test/providers/trackers_provider_test.dart create mode 100644 test/providers/trakt_account_provider_test.dart diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index eab6249b..e1da9ded 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; @@ -19,6 +20,11 @@ enum OfflineActionType { progress, watched, unwatched } class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); + /// Test-only constructor — inject an in-memory [QueryExecutor] + /// (e.g. `NativeDatabase.memory()`) so tests don't touch real disk. + @visibleForTesting + AppDatabase.forTesting(super.e); + @override int get schemaVersion => 13; diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart new file mode 100644 index 00000000..68374058 --- /dev/null +++ b/test/database/app_database_test.dart @@ -0,0 +1,566 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/download_models.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + // ============================================================ + // Schema sanity + // ============================================================ + + group('schema', () { + test('schemaVersion is 13', () { + expect(db.schemaVersion, 13); + }); + + test('all tables are accessible and start empty', () async { + expect(await db.select(db.downloadedMedia).get(), isEmpty); + expect(await db.select(db.downloadQueue).get(), isEmpty); + expect(await db.select(db.apiCache).get(), isEmpty); + expect(await db.select(db.offlineWatchProgress).get(), isEmpty); + expect(await db.select(db.syncRules).get(), isEmpty); + }); + }); + + // ============================================================ + // ApiCache: insert / select / update / delete round-trip + // ============================================================ + + group('ApiCache', () { + test('insert + select round-trip preserves fields', () async { + await db + .into(db.apiCache) + .insert(ApiCacheCompanion.insert(cacheKey: 'srv:/library/metadata/1', data: '{"hello":"world"}')); + + final rows = await db.select(db.apiCache).get(); + expect(rows, hasLength(1)); + expect(rows.first.cacheKey, 'srv:/library/metadata/1'); + expect(rows.first.data, '{"hello":"world"}'); + expect(rows.first.pinned, isFalse); // default + }); + + test('default pinned=false, custom pinned=true is honored', () async { + await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k1', data: 'a')); + await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k2', data: 'b', pinned: const Value(true))); + + final rows = await (db.select(db.apiCache)..orderBy([(t) => OrderingTerm.asc(t.cacheKey)])).get(); + expect(rows.map((r) => r.pinned).toList(), [false, true]); + }); + + test('cacheKey is the primary key (duplicate insert without replace fails)', () async { + await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'first')); + expect( + () => db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'second')), + throwsA(isA()), + ); + }); + + test('insertOnConflictUpdate replaces the row', () async { + await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'first')); + await db + .into(db.apiCache) + .insertOnConflictUpdate(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'second', pinned: const Value(true))); + + final rows = await db.select(db.apiCache).get(); + expect(rows, hasLength(1)); + expect(rows.first.data, 'second'); + expect(rows.first.pinned, isTrue); + }); + + test('update modifies existing row', () async { + await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k', data: 'orig')); + await (db.update( + db.apiCache, + )..where((t) => t.cacheKey.equals('k'))).write(const ApiCacheCompanion(data: Value('updated'))); + + final row = await (db.select(db.apiCache)..where((t) => t.cacheKey.equals('k'))).getSingle(); + expect(row.data, 'updated'); + }); + + test('delete removes the row', () async { + await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k', data: 'v')); + expect(await db.select(db.apiCache).get(), hasLength(1)); + + await (db.delete(db.apiCache)..where((t) => t.cacheKey.equals('k'))).go(); + expect(await db.select(db.apiCache).get(), isEmpty); + }); + }); + + // ============================================================ + // DownloadedMedia: round-trip + helpers + update + delete + // ============================================================ + + group('DownloadedMedia', () { + Future insertMovie({ + String serverId = 'srv1', + String ratingKey = '100', + int status = 0, // queued + int progress = 0, + }) async { + return db + .into(db.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: serverId, + ratingKey: ratingKey, + globalKey: '$serverId:$ratingKey', + type: 'movie', + status: status, + progress: Value(progress), + ), + ); + } + + test('insert + select round-trip preserves fields', () async { + await insertMovie(); + + final rows = await db.select(db.downloadedMedia).get(); + expect(rows, hasLength(1)); + expect(rows.first.serverId, 'srv1'); + expect(rows.first.ratingKey, '100'); + expect(rows.first.globalKey, 'srv1:100'); + expect(rows.first.type, 'movie'); + expect(rows.first.status, 0); + expect(rows.first.progress, 0); + expect(rows.first.downloadedBytes, 0); // default + expect(rows.first.retryCount, 0); // default + expect(rows.first.mediaIndex, 0); // default + expect(rows.first.bgTaskId, isNull); + expect(rows.first.totalBytes, isNull); + }); + + test('updating progress field works', () async { + await insertMovie(); + await (db.update(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).write( + const DownloadedMediaCompanion(progress: Value(75), downloadedBytes: Value(1024)), + ); + + final row = await (db.select(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).getSingle(); + expect(row.progress, 75); + expect(row.downloadedBytes, 1024); + }); + + test('globalKey unique constraint blocks duplicate insert', () async { + await insertMovie(); + expect(insertMovie(), throwsA(isA())); + }); + + test('delete removes only the matching row', () async { + await insertMovie(ratingKey: '1'); + await insertMovie(ratingKey: '2'); + expect(await db.select(db.downloadedMedia).get(), hasLength(2)); + + await (db.delete(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:1'))).go(); + + final rows = await db.select(db.downloadedMedia).get(); + expect(rows, hasLength(1)); + expect(rows.first.ratingKey, '2'); + }); + + test('getAllDownloadedMetadata returns only completed items', () async { + await insertMovie(ratingKey: '1', status: DownloadStatus.queued.index); + await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index); + await insertMovie(ratingKey: '3', status: DownloadStatus.failed.index); + await insertMovie(ratingKey: '4', status: DownloadStatus.completed.index); + + final completed = await db.getAllDownloadedMetadata(); + expect(completed.map((i) => i.ratingKey).toSet(), {'2', '4'}); + }); + }); + + // ============================================================ + // OfflineWatchProgress helpers + // ============================================================ + + group('OfflineWatchProgress', () { + test('upsertProgressAction inserts a new progress row', () async { + await db.upsertProgressAction( + serverId: 'srv', + ratingKey: '42', + viewOffset: 5000, + duration: 10000, + shouldMarkWatched: false, + ); + + final rows = await db.select(db.offlineWatchProgress).get(); + expect(rows, hasLength(1)); + expect(rows.first.globalKey, 'srv:42'); + expect(rows.first.actionType, OfflineActionType.progress.name); + expect(rows.first.viewOffset, 5000); + expect(rows.first.duration, 10000); + expect(rows.first.shouldMarkWatched, isFalse); + expect(rows.first.syncAttempts, 0); + }); + + test('upsertProgressAction merges into the existing progress row', () async { + await db.upsertProgressAction( + serverId: 'srv', + ratingKey: '42', + viewOffset: 1000, + duration: 10000, + shouldMarkWatched: false, + ); + await db.upsertProgressAction( + serverId: 'srv', + ratingKey: '42', + viewOffset: 9500, + duration: 10000, + shouldMarkWatched: true, + ); + + final rows = await db.select(db.offlineWatchProgress).get(); + expect(rows, hasLength(1)); + expect(rows.first.viewOffset, 9500); + expect(rows.first.shouldMarkWatched, isTrue); + }); + + test('insertWatchAction (watched) clears prior progress + insert single row', () async { + // Existing progress row for the same item + await db.upsertProgressAction( + serverId: 'srv', + ratingKey: '42', + viewOffset: 5000, + duration: 10000, + shouldMarkWatched: false, + ); + + await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.name); + + final rows = await db.select(db.offlineWatchProgress).get(); + expect(rows, hasLength(1)); + expect(rows.first.actionType, OfflineActionType.watched.name); + expect(rows.first.viewOffset, isNull); + }); + + test('getPendingWatchActions returns rows ordered by createdAt asc', () async { + // Inject deterministic createdAt by raw inserts + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + ratingKey: '1', + globalKey: 's:1', + actionType: OfflineActionType.watched.name, + createdAt: now + 100, + updatedAt: now + 100, + ), + ); + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + ratingKey: '2', + globalKey: 's:2', + actionType: OfflineActionType.watched.name, + createdAt: now + 50, + updatedAt: now + 50, + ), + ); + + final pending = await db.getPendingWatchActions(); + expect(pending.map((p) => p.ratingKey).toList(), ['2', '1']); + }); + + test('getPendingWatchActionsForServer filters by serverId', () async { + await db.insertWatchAction(serverId: 'a', ratingKey: '1', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 'b', ratingKey: '2', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 'a', ratingKey: '3', actionType: OfflineActionType.unwatched.name); + + final aRows = await db.getPendingWatchActionsForServer('a'); + expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'}); + + final bRows = await db.getPendingWatchActionsForServer('b'); + expect(bRows.map((r) => r.ratingKey).toSet(), {'2'}); + }); + + test('getLatestWatchAction picks the most recently updated row', () async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + ratingKey: '1', + globalKey: 's:1', + actionType: OfflineActionType.progress.name, + createdAt: now, + updatedAt: now - 100, + ), + ); + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + ratingKey: '1', + globalKey: 's:1', + actionType: OfflineActionType.watched.name, + createdAt: now, + updatedAt: now + 50, + ), + ); + + final latest = await db.getLatestWatchAction('s:1'); + expect(latest, isNotNull); + expect(latest!.actionType, OfflineActionType.watched.name); + }); + + test('getLatestWatchAction returns null when no rows', () async { + expect(await db.getLatestWatchAction('nope:nope'), isNull); + }); + + test('getLatestWatchActionsForKeys batches lookups, latest per key', () async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + ratingKey: '1', + globalKey: 's:1', + actionType: OfflineActionType.progress.name, + createdAt: now, + updatedAt: now, + ), + ); + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + ratingKey: '1', + globalKey: 's:1', + actionType: OfflineActionType.watched.name, + createdAt: now, + updatedAt: now + 100, + ), + ); + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + ratingKey: '2', + globalKey: 's:2', + actionType: OfflineActionType.unwatched.name, + createdAt: now, + updatedAt: now, + ), + ); + + final result = await db.getLatestWatchActionsForKeys({'s:1', 's:2', 's:3-missing'}); + expect(result.keys.toSet(), {'s:1', 's:2'}); + expect(result['s:1']!.actionType, OfflineActionType.watched.name); + expect(result['s:2']!.actionType, OfflineActionType.unwatched.name); + }); + + test('getLatestWatchActionsForKeys with empty input returns empty map (no query)', () async { + expect(await db.getLatestWatchActionsForKeys({}), isEmpty); + }); + + test('updateSyncAttempt increments syncAttempts and stores lastError', () async { + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); + final inserted = (await db.select(db.offlineWatchProgress).get()).single; + + await db.updateSyncAttempt(inserted.id, 'boom'); + var row = (await db.select(db.offlineWatchProgress).get()).single; + expect(row.syncAttempts, 1); + expect(row.lastError, 'boom'); + + await db.updateSyncAttempt(inserted.id, null); + row = (await db.select(db.offlineWatchProgress).get()).single; + expect(row.syncAttempts, 2); + expect(row.lastError, isNull); + }); + + test('updateSyncAttempt is a no-op when id does not exist', () async { + await db.updateSyncAttempt(999, 'irrelevant'); + expect(await db.select(db.offlineWatchProgress).get(), isEmpty); + }); + + test('deleteWatchAction removes only the matching row', () async { + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.name); + final rows = await db.select(db.offlineWatchProgress).get(); + expect(rows, hasLength(2)); + + await db.deleteWatchAction(rows.first.id); + expect(await db.select(db.offlineWatchProgress).get(), hasLength(1)); + }); + + test('getPendingSyncCount counts every row', () async { + expect(await db.getPendingSyncCount(), 0); + + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name); + expect(await db.getPendingSyncCount(), 2); + }); + + test('clearAllWatchActions empties the table', () async { + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name); + + await db.clearAllWatchActions(); + expect(await db.select(db.offlineWatchProgress).get(), isEmpty); + }); + }); + + // ============================================================ + // Sync Rules helpers + // ============================================================ + + group('SyncRules', () { + test('insertSyncRule + getSyncRules round-trip with defaults', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + + final rules = await db.getSyncRules(); + expect(rules, hasLength(1)); + expect(rules.first.targetType, 'show'); + expect(rules.first.episodeCount, 5); + expect(rules.first.enabled, isTrue); // default + expect(rules.first.downloadFilter, 'unwatched'); // default + expect(rules.first.mediaIndex, 0); // default + expect(rules.first.lastExecutedAt, isNull); + }); + + test('insertSyncRule with duplicate globalKey throws on the UNIQUE constraint', () async { + // insertOnConflictUpdate only auto-targets the primary key (`id`), so a + // collision on the UNIQUE `global_key` column still throws — this pins + // current production behavior. + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + expect( + () => db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'season', + episodeCount: 99, + ), + throwsA(isA()), + ); + }); + + test('getSyncRule returns the matching rule or null', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + expect(await db.getSyncRule('srv:10'), isNotNull); + expect(await db.getSyncRule('srv:nope'), isNull); + }); + + test('updateSyncRuleCount mutates only the count', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + await db.updateSyncRuleCount('srv:10', 12); + + final rule = await db.getSyncRule('srv:10'); + expect(rule!.episodeCount, 12); + expect(rule.targetType, 'show'); // unchanged + }); + + test('updateSyncRuleFilter mutates the filter', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + await db.updateSyncRuleFilter('srv:10', 'all'); + + final rule = await db.getSyncRule('srv:10'); + expect(rule!.downloadFilter, 'all'); + }); + + test('updateSyncRuleEnabled toggles enabled', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + await db.updateSyncRuleEnabled('srv:10', false); + expect((await db.getSyncRule('srv:10'))!.enabled, isFalse); + + await db.updateSyncRuleEnabled('srv:10', true); + expect((await db.getSyncRule('srv:10'))!.enabled, isTrue); + }); + + test('updateSyncRuleLastExecuted writes a timestamp', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + final before = DateTime.now().millisecondsSinceEpoch; + await db.updateSyncRuleLastExecuted('srv:10'); + final after = DateTime.now().millisecondsSinceEpoch; + + final rule = await db.getSyncRule('srv:10'); + expect(rule!.lastExecutedAt, isNotNull); + expect(rule.lastExecutedAt! >= before, isTrue); + expect(rule.lastExecutedAt! <= after, isTrue); + }); + + test('deleteSyncRule removes the matching row', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '11', + globalKey: 'srv:11', + targetType: 'show', + episodeCount: 5, + ); + + await db.deleteSyncRule('srv:10'); + + final remaining = await db.getSyncRules(); + expect(remaining, hasLength(1)); + expect(remaining.first.globalKey, 'srv:11'); + }); + }); +} diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart new file mode 100644 index 00000000..22d18d76 --- /dev/null +++ b/test/database/download_operations_test.dart @@ -0,0 +1,429 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/models/download_models.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + // ============================================================ + // insertDownload + // ============================================================ + + group('insertDownload', () { + test('inserts a movie row with defaults', () async { + await db.insertDownload( + serverId: 'srv', + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + ); + + final rows = await db.select(db.downloadedMedia).get(); + expect(rows, hasLength(1)); + final r = rows.first; + expect(r.serverId, 'srv'); + expect(r.ratingKey, '100'); + expect(r.globalKey, 'srv:100'); + expect(r.type, 'movie'); + expect(r.status, DownloadStatus.queued.index); + expect(r.parentRatingKey, isNull); + expect(r.grandparentRatingKey, isNull); + expect(r.mediaIndex, 0); + }); + + test('inserts an episode with parent and grandparent keys', () async { + await db.insertDownload( + serverId: 'srv', + ratingKey: 'ep1', + globalKey: 'srv:ep1', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.queued.index, + mediaIndex: 7, + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.parentRatingKey, 'season1'); + expect(row.grandparentRatingKey, 'show1'); + expect(row.mediaIndex, 7); + }); + + test('insertDownload uses InsertMode.insertOrReplace (re-insert overwrites)', () async { + await db.insertDownload( + serverId: 'srv', + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + ); + // Mark progress so we can detect a replace. + await db.updateDownloadProgress('srv:100', 50, 500, 1000); + + // Re-insert with the same globalKey — should replace, resetting progress to default 0. + await db.insertDownload( + serverId: 'srv', + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.failed.index, + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.status, DownloadStatus.failed.index); + expect(row.progress, 0); + expect(row.downloadedBytes, 0); + }); + }); + + // ============================================================ + // Download queue + getNextQueueItem + // ============================================================ + + group('queue', () { + test('addToQueue inserts a row with defaults', () async { + await db.addToQueue(mediaGlobalKey: 'srv:100'); + + final rows = await db.select(db.downloadQueue).get(); + expect(rows, hasLength(1)); + expect(rows.first.mediaGlobalKey, 'srv:100'); + expect(rows.first.priority, 0); + expect(rows.first.downloadSubtitles, isTrue); + expect(rows.first.downloadArtwork, isTrue); + }); + + test('addToQueue stores custom priority and toggles', () async { + await db.addToQueue(mediaGlobalKey: 'srv:100', priority: 5, downloadSubtitles: false, downloadArtwork: false); + + final row = (await db.select(db.downloadQueue).get()).single; + expect(row.priority, 5); + expect(row.downloadSubtitles, isFalse); + expect(row.downloadArtwork, isFalse); + }); + + test('addToQueue replaces row with same mediaGlobalKey (unique)', () async { + await db.addToQueue(mediaGlobalKey: 'srv:100', priority: 1); + await db.addToQueue(mediaGlobalKey: 'srv:100', priority: 9); + + final rows = await db.select(db.downloadQueue).get(); + expect(rows, hasLength(1)); + expect(rows.first.priority, 9); + }); + + test('removeFromQueue deletes the matching row', () async { + await db.addToQueue(mediaGlobalKey: 'srv:1'); + await db.addToQueue(mediaGlobalKey: 'srv:2'); + + await db.removeFromQueue('srv:1'); + + final rows = await db.select(db.downloadQueue).get(); + expect(rows, hasLength(1)); + expect(rows.first.mediaGlobalKey, 'srv:2'); + }); + + test('getNextQueueItem returns null when empty', () async { + expect(await db.getNextQueueItem(), isNull); + }); + + 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: 'srv', + ratingKey: '1', + globalKey: 'srv:1', + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.insertDownload( + serverId: 'srv', + ratingKey: '2', + globalKey: 'srv:2', + type: 'movie', + status: DownloadStatus.downloading.index, + ); + + await db.addToQueue(mediaGlobalKey: 'srv:1', priority: 1); + await db.addToQueue(mediaGlobalKey: 'srv:2', priority: 10); + + 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: 'srv', + ratingKey: '1', + globalKey: 'srv:1', + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.insertDownload( + serverId: 'srv', + ratingKey: '2', + globalKey: 'srv:2', + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.insertDownload( + serverId: 'srv', + ratingKey: '3', + globalKey: 'srv:3', + type: 'movie', + status: DownloadStatus.queued.index, + ); + + // Manually inject deterministic addedAt so the test isn't time-dependent. + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.downloadQueue) + .insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'srv:1', priority: const Value(1), addedAt: now)); + await db + .into(db.downloadQueue) + .insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'srv:2', priority: const Value(5), addedAt: now + 100)); + await db + .into(db.downloadQueue) + .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'); + }); + }); + + // ============================================================ + // Update helpers + // ============================================================ + + group('update helpers', () { + Future seed({String key = 'srv:100'}) async { + await db.insertDownload( + serverId: key.split(':').first, + ratingKey: key.split(':').last, + globalKey: key, + type: 'movie', + status: DownloadStatus.queued.index, + ); + } + + test('updateDownloadStatus changes only status', () async { + await seed(); + await db.updateDownloadStatus('srv:100', DownloadStatus.downloading.index); + + final r = (await db.select(db.downloadedMedia).get()).single; + expect(r.status, DownloadStatus.downloading.index); + expect(r.progress, 0); // untouched + }); + + test('updateDownloadProgress writes progress + bytes', () async { + await seed(); + await db.updateDownloadProgress('srv:100', 42, 4242, 9999); + + final r = (await db.select(db.downloadedMedia).get()).single; + expect(r.progress, 42); + expect(r.downloadedBytes, 4242); + expect(r.totalBytes, 9999); + }); + + test('updateVideoFilePath sets path and downloadedAt timestamp', () async { + await seed(); + final before = DateTime.now().millisecondsSinceEpoch; + await db.updateVideoFilePath('srv:100', '/tmp/file.mkv'); + final after = DateTime.now().millisecondsSinceEpoch; + + final r = (await db.select(db.downloadedMedia).get()).single; + expect(r.videoFilePath, '/tmp/file.mkv'); + expect(r.downloadedAt, isNotNull); + expect(r.downloadedAt! >= before, isTrue); + expect(r.downloadedAt! <= after, isTrue); + }); + + test('updateArtworkPaths sets thumbPath; null clears it', () async { + await seed(); + await db.updateArtworkPaths(globalKey: 'srv:100', thumbPath: '/tmp/thumb.jpg'); + expect((await db.select(db.downloadedMedia).get()).single.thumbPath, '/tmp/thumb.jpg'); + + await db.updateArtworkPaths(globalKey: 'srv:100', thumbPath: null); + expect((await db.select(db.downloadedMedia).get()).single.thumbPath, isNull); + }); + + test('updateDownloadError stores message and increments retryCount', () async { + await seed(); + + await db.updateDownloadError('srv:100', 'first'); + var r = (await db.select(db.downloadedMedia).get()).single; + expect(r.errorMessage, 'first'); + expect(r.retryCount, 1); + + await db.updateDownloadError('srv:100', 'second'); + r = (await db.select(db.downloadedMedia).get()).single; + expect(r.errorMessage, 'second'); + expect(r.retryCount, 2); + }); + + test('clearDownloadError nulls the message and resets retryCount to 0', () async { + await seed(); + await db.updateDownloadError('srv:100', 'oops'); + + await db.clearDownloadError('srv:100'); + final r = (await db.select(db.downloadedMedia).get()).single; + expect(r.errorMessage, isNull); + expect(r.retryCount, 0); + }); + + test('updateBgTaskId / getBgTaskId round-trip', () async { + await seed(); + expect(await db.getBgTaskId('srv:100'), isNull); + + await db.updateBgTaskId('srv:100', 'task-abc'); + expect(await db.getBgTaskId('srv:100'), 'task-abc'); + + await db.updateBgTaskId('srv:100', null); + expect(await db.getBgTaskId('srv:100'), isNull); + }); + + test('getBgTaskId on missing globalKey returns null', () async { + expect(await db.getBgTaskId('does:not-exist'), isNull); + }); + }); + + // ============================================================ + // Lookup helpers + // ============================================================ + + group('lookup helpers', () { + Future seedTree() async { + await db.insertDownload( + serverId: 'srvA', + ratingKey: 'ep1', + globalKey: 'srvA:ep1', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + await db.insertDownload( + serverId: 'srvA', + ratingKey: 'ep2', + globalKey: 'srvA:ep2', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + await db.insertDownload( + serverId: 'srvA', + ratingKey: 'ep3', + globalKey: 'srvA:ep3', + type: 'episode', + parentRatingKey: 'season2', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + await db.insertDownload( + serverId: 'srvB', + ratingKey: 'movie1', + globalKey: 'srvB:movie1', + type: 'movie', + status: DownloadStatus.queued.index, + ); + } + + test('getDownloadedMedia returns the matching row or null', () async { + await seedTree(); + + final hit = await db.getDownloadedMedia('srvA:ep1'); + expect(hit, isNotNull); + expect(hit!.ratingKey, 'ep1'); + + expect(await db.getDownloadedMedia('nope:nope'), isNull); + }); + + test('getEpisodesBySeason filters by parentRatingKey', () async { + await seedTree(); + + final s1 = await db.getEpisodesBySeason('season1'); + expect(s1.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2'}); + + final s2 = await db.getEpisodesBySeason('season2'); + expect(s2.map((e) => e.ratingKey).toSet(), {'ep3'}); + + expect(await db.getEpisodesBySeason('seasonZ'), isEmpty); + }); + + test('getEpisodesByShow filters by grandparentRatingKey', () async { + await seedTree(); + + final all = await db.getEpisodesByShow('show1'); + expect(all.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2', 'ep3'}); + + expect(await db.getEpisodesByShow('show-missing'), isEmpty); + }); + + test('getDownloadsByServerId filters by serverId', () async { + await seedTree(); + + final a = await db.getDownloadsByServerId('srvA'); + expect(a.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2', 'ep3'}); + + final b = await db.getDownloadsByServerId('srvB'); + expect(b.map((e) => e.ratingKey).toSet(), {'movie1'}); + + expect(await db.getDownloadsByServerId('srvZ'), isEmpty); + }); + }); + + // ============================================================ + // deleteDownload — removes from both tables + // ============================================================ + + group('deleteDownload', () { + test('removes the row from downloadedMedia AND its queue entry', () async { + await db.insertDownload( + serverId: 'srv', + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.addToQueue(mediaGlobalKey: 'srv:100'); + await db.insertDownload( + serverId: 'srv', + ratingKey: '200', + globalKey: 'srv:200', + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.addToQueue(mediaGlobalKey: 'srv:200'); + + await db.deleteDownload('srv:100'); + + final media = await db.select(db.downloadedMedia).get(); + expect(media.map((m) => m.globalKey).toList(), ['srv:200']); + + final queue = await db.select(db.downloadQueue).get(); + expect(queue.map((q) => q.mediaGlobalKey).toList(), ['srv:200']); + }); + + test('deleteDownload on a missing globalKey is a no-op', () async { + // Should not throw. + await db.deleteDownload('nope:nope'); + expect(await db.select(db.downloadedMedia).get(), isEmpty); + expect(await db.select(db.downloadQueue).get(), isEmpty); + }); + }); +} diff --git a/test/providers/libraries_provider_test.dart b/test/providers/libraries_provider_test.dart new file mode 100644 index 00000000..64f3d3b6 --- /dev/null +++ b/test/providers/libraries_provider_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex_library.dart'; +import 'package:plezy/providers/libraries_provider.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +PlexLibrary _lib(String key, {String type = 'movie', String? serverId, String title = 'L'}) => + PlexLibrary(key: key, title: title, type: type, serverId: serverId); + +void main() { + setUp(resetSharedPreferencesForTest); + + group('LibrariesProvider', () { + test('starts with initial empty state', () { + final p = LibrariesProvider(); + expect(p.libraries, isEmpty); + expect(p.hasLibraries, isFalse); + expect(p.isLoading, isFalse); + expect(p.hasLoaded, isFalse); + expect(p.loadState, LibrariesLoadState.initial); + expect(p.errorMessage, isNull); + p.dispose(); + }); + + test('loadLibraries before initialize is a no-op', () async { + final p = LibrariesProvider(); + var notified = 0; + p.addListener(() => notified++); + + await p.loadLibraries(); + + // Without a DataAggregationService the load short-circuits with no + // state transition and no listener notification. + expect(p.loadState, LibrariesLoadState.initial); + expect(p.libraries, isEmpty); + expect(notified, 0); + + p.dispose(); + }); + + test('refresh before initialize is a no-op', () async { + final p = LibrariesProvider(); + var notified = 0; + p.addListener(() => notified++); + + await p.refresh(); + expect(p.loadState, LibrariesLoadState.initial); + expect(notified, 0); + + p.dispose(); + }); + + test('updateLibraryOrder updates list, notifies, and persists order', () async { + final p = LibrariesProvider(); + var notified = 0; + p.addListener(() => notified++); + + final libs = [ + _lib('1', serverId: 'srv', title: 'A'), + _lib('2', serverId: 'srv', title: 'B'), + _lib('3', serverId: 'srv', title: 'C'), + ]; + + await p.updateLibraryOrder(libs); + expect(p.libraries.length, 3); + expect(p.libraries.map((l) => l.title), ['A', 'B', 'C']); + expect(notified, 1); + + // Persisted to storage as the list of globalKeys. + final storage = await StorageService.getInstance(); + expect(storage.getLibraryOrder(), equals(libs.map((l) => l.globalKey).toList())); + + p.dispose(); + }); + + test('libraries getter returns an unmodifiable list', () async { + final p = LibrariesProvider(); + await p.updateLibraryOrder([_lib('1', serverId: 'srv')]); + expect(() => p.libraries.add(_lib('mutated')), throwsUnsupportedError); + p.dispose(); + }); + + test('clear resets state to initial and notifies', () async { + final p = LibrariesProvider(); + await p.updateLibraryOrder([_lib('1', serverId: 'srv'), _lib('2', serverId: 'srv')]); + expect(p.libraries, hasLength(2)); + + var notified = 0; + p.addListener(() => notified++); + + p.clear(); + expect(p.libraries, isEmpty); + expect(p.hasLibraries, isFalse); + expect(p.loadState, LibrariesLoadState.initial); + expect(p.errorMessage, isNull); + expect(notified, 1); + + p.dispose(); + }); + + test('safeNotifyListeners after dispose is a no-op', () async { + final p = LibrariesProvider(); + p.dispose(); + // Post-dispose clear / updateLibraryOrder must not throw — the provider + // uses `safeNotifyListeners` which swallows post-dispose firings. + p.clear(); + await p.updateLibraryOrder([_lib('1', serverId: 'srv')]); + }); + }); +} diff --git a/test/providers/settings_provider_test.dart b/test/providers/settings_provider_test.dart new file mode 100644 index 00000000..648f9b47 --- /dev/null +++ b/test/providers/settings_provider_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/transcode_quality_preset.dart'; +import 'package:plezy/providers/settings_provider.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + setUp(resetSharedPreferencesForTest); + + group('SettingsProvider', () { + test('starts uninitialized and exposes defaults', () { + final p = SettingsProvider(); + expect(p.isInitialized, isFalse); + expect(p.isReady, isFalse); + // Pre-init getters fall back to declared defaults. + expect(p.libraryDensity, LibraryDensity.defaultValue); + expect(p.viewMode, ViewMode.grid); + expect(p.episodePosterMode, EpisodePosterMode.episodeThumbnail); + expect(p.showHeroSection, isTrue); + expect(p.useGlobalHubs, isTrue); + expect(p.showServerNameOnHubs, isFalse); + expect(p.hideSpoilers, isFalse); + expect(p.defaultQualityPreset, TranscodeQualityPreset.original); + p.dispose(); + }); + + test('ensureInitialized completes and flips isInitialized', () async { + final p = SettingsProvider(); + expect(p.isInitialized, isFalse); + await p.ensureInitialized(); + expect(p.isInitialized, isTrue); + expect(p.isReady, isTrue); + p.dispose(); + }); + + test('setLibraryDensity clamps and persists', () async { + final p = SettingsProvider(); + await p.ensureInitialized(); + + var notified = 0; + p.addListener(() => notified++); + + // Above max → clamped to max. + await p.setLibraryDensity(LibraryDensity.max + 5); + expect(p.libraryDensity, LibraryDensity.max); + expect(notified, 1); + + // Below min → clamped to min. + await p.setLibraryDensity(LibraryDensity.min - 5); + expect(p.libraryDensity, LibraryDensity.min); + expect(notified, 2); + + // Verify persisted directly via the service. + final svc = await SettingsService.getInstance(); + expect(svc.read(SettingsService.libraryDensity), LibraryDensity.min); + + p.dispose(); + }); + + test('setShowHeroSection toggles, notifies, and is a no-op for same value', () async { + final p = SettingsProvider(); + await p.ensureInitialized(); + + var notified = 0; + p.addListener(() => notified++); + + expect(p.showHeroSection, isTrue); + await p.setShowHeroSection(false); + expect(p.showHeroSection, isFalse); + expect(notified, 1); + + // Same value → no notify. + await p.setShowHeroSection(false); + expect(notified, 1); + + // Flip back → notify again. + await p.setShowHeroSection(true); + expect(p.showHeroSection, isTrue); + expect(notified, 2); + + p.dispose(); + }); + + test('setDefaultQualityPreset round-trips through TranscodeQualityPreset', () async { + final p = SettingsProvider(); + await p.ensureInitialized(); + + await p.setDefaultQualityPreset(TranscodeQualityPreset.p1080_10mbps); + expect(p.defaultQualityPreset, TranscodeQualityPreset.p1080_10mbps); + + // Verify the underlying string storage uses the storageKey. + final svc = await SettingsService.getInstance(); + expect(svc.read(SettingsService.defaultQualityPreset), TranscodeQualityPreset.p1080_10mbps.storageKey); + + // Round-trip a different preset. + await p.setDefaultQualityPreset(TranscodeQualityPreset.p720_2mbps); + expect(p.defaultQualityPreset, TranscodeQualityPreset.p720_2mbps); + + p.dispose(); + }); + + test('setViewMode persists enum by name', () async { + final p = SettingsProvider(); + await p.ensureInitialized(); + + expect(p.viewMode, ViewMode.grid); + await p.setViewMode(ViewMode.list); + expect(p.viewMode, ViewMode.list); + + final svc = await SettingsService.getInstance(); + expect(svc.read(SettingsService.viewMode), ViewMode.list); + + p.dispose(); + }); + + test('reload re-reads after external mutation', () async { + final p = SettingsProvider(); + await p.ensureInitialized(); + expect(p.hideSpoilers, isFalse); + + // Mutate via the service directly (simulates an import / reset). + final svc = await SettingsService.getInstance(); + await svc.write(SettingsService.hideSpoilers, true); + + var notified = 0; + p.addListener(() => notified++); + + await p.reload(); + expect(p.hideSpoilers, isTrue); + expect(notified, 1); + + p.dispose(); + }); + + test('persists across provider instances via SharedPreferences', () async { + final first = SettingsProvider(); + await first.ensureInitialized(); + await first.setShowNavBarLabels(false); + await first.setLibraryDensity(5); + first.dispose(); + + // Reset only the cached singleton — backing store is preserved. + BaseSharedPreferencesService.resetForTesting(); + + final second = SettingsProvider(); + await second.ensureInitialized(); + expect(second.showNavBarLabels, isFalse); + expect(second.libraryDensity, 5); + second.dispose(); + }); + + test('safeNotifyListeners no-ops after dispose', () async { + final p = SettingsProvider(); + await p.ensureInitialized(); + p.dispose(); + // Should not throw — reload calls safeNotifyListeners under the hood. + await p.reload(); + }); + }); +} diff --git a/test/providers/shader_provider_test.dart b/test/providers/shader_provider_test.dart new file mode 100644 index 00000000..3b806204 --- /dev/null +++ b/test/providers/shader_provider_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/shader_preset.dart'; +import 'package:plezy/providers/shader_provider.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + setUp(resetSharedPreferencesForTest); + + group('ShaderProvider', () { + test('starts uninitialized and exposes the none preset by default', () async { + final p = ShaderProvider(); + expect(p.initialized, isFalse); + expect(p.savedPreset, ShaderPreset.none); + expect(p.currentPreset, ShaderPreset.none); + expect(p.customPresets, isEmpty); + expect(p.isShaderEnabled, isFalse); + + // Wait for the eager async _initialize() to finish. + await Future.delayed(Duration.zero); + expect(p.initialized, isTrue); + expect(p.savedPreset, ShaderPreset.none); + p.dispose(); + }); + + test('allPresets exposes built-in presets and includes none + nvscaler', () async { + final p = ShaderProvider(); + await Future.delayed(Duration.zero); + + // Built-ins should always be present even with no custom presets stored. + final ids = p.allPresets.map((preset) => preset.id).toList(); + expect(ids, contains(ShaderPreset.none.id)); + expect(ids, contains(ShaderPreset.nvscalerDefault.id)); + expect(p.allPresets.length, ShaderPreset.allPresets.length); + + p.dispose(); + }); + + test('setPreset persists, updates current/saved, and notifies', () async { + final p = ShaderProvider(); + await Future.delayed(Duration.zero); + + var notified = 0; + p.addListener(() => notified++); + + await p.setPreset(ShaderPreset.nvscalerDefault); + expect(p.savedPreset, ShaderPreset.nvscalerDefault); + expect(p.currentPreset, ShaderPreset.nvscalerDefault); + 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); + + p.dispose(); + }); + + test('setCurrentPreset updates current without persisting and is a no-op for same id', () async { + final p = ShaderProvider(); + await Future.delayed(Duration.zero); + await p.setPreset(ShaderPreset.nvscalerDefault); + + var notified = 0; + p.addListener(() => notified++); + + // Toggle off transiently — saved should stay nvscaler, current goes none. + p.setCurrentPreset(ShaderPreset.none); + expect(p.currentPreset, ShaderPreset.none); + expect(p.savedPreset, ShaderPreset.nvscalerDefault); + expect(notified, 1); + + // Same id → no notify. + p.setCurrentPreset(ShaderPreset.none); + expect(notified, 1); + + // Persisted store should still hold nvscaler since we never called setPreset. + final svc = await SettingsService.getInstance(); + expect(svc.read(SettingsService.globalShaderPreset), ShaderPreset.nvscalerDefault.id); + + p.dispose(); + }); + + test('reset returns to the none preset', () async { + final p = ShaderProvider(); + await Future.delayed(Duration.zero); + await p.setPreset(ShaderPreset.nvscalerDefault); + expect(p.isShaderEnabled, isTrue); + + await p.reset(); + expect(p.savedPreset, ShaderPreset.none); + expect(p.currentPreset, ShaderPreset.none); + expect(p.isShaderEnabled, isFalse); + + final svc = await SettingsService.getInstance(); + expect(svc.read(SettingsService.globalShaderPreset), ShaderPreset.none.id); + + p.dispose(); + }); + + test('findPresetById returns built-in presets and null for unknown ids', () async { + final p = ShaderProvider(); + await Future.delayed(Duration.zero); + + expect(p.findPresetById(ShaderPreset.nvscalerDefault.id), ShaderPreset.nvscalerDefault); + expect(p.findPresetById(ShaderPreset.none.id), ShaderPreset.none); + expect(p.findPresetById('not-a-real-preset'), isNull); + + p.dispose(); + }); + + test('persists selected preset across provider instances via SharedPreferences', () async { + final first = ShaderProvider(); + await Future.delayed(Duration.zero); + await first.setPreset(ShaderPreset.nvscalerDefault); + first.dispose(); + + // Reset only the cached singleton — backing store is preserved. + BaseSharedPreferencesService.resetForTesting(); + + final second = ShaderProvider(); + await Future.delayed(Duration.zero); + expect(second.savedPreset, ShaderPreset.nvscalerDefault); + expect(second.currentPreset, ShaderPreset.nvscalerDefault); + second.dispose(); + }); + + test('initializes from a previously persisted preset id', () async { + // Pre-populate the persisted preset id before the provider boots. + final svc = await SettingsService.getInstance(); + await svc.write(SettingsService.globalShaderPreset, ShaderPreset.nvscalerDefault.id); + + final p = ShaderProvider(); + await Future.delayed(Duration.zero); + expect(p.savedPreset, ShaderPreset.nvscalerDefault); + expect(p.currentPreset, ShaderPreset.nvscalerDefault); + + p.dispose(); + }); + + test('safeNotifyListeners no-ops after dispose', () async { + final p = ShaderProvider(); + await Future.delayed(Duration.zero); + p.dispose(); + // Should not throw — setPreset calls safeNotifyListeners under the hood. + await p.setPreset(ShaderPreset.none); + }); + }); +} diff --git a/test/providers/theme_provider_test.dart b/test/providers/theme_provider_test.dart new file mode 100644 index 00000000..a604c2e8 --- /dev/null +++ b/test/providers/theme_provider_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/providers/theme_provider.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/settings_service.dart' as settings; + +import '../test_helpers/prefs.dart'; + +void main() { + setUp(resetSharedPreferencesForTest); + + group('ThemeProvider', () { + test('exposes a non-null themeMode immediately and flips after init', () async { + final p = ThemeProvider(); + // The constructor synchronously sets _themeMode to system. + expect(p.themeMode, settings.ThemeMode.system); + + // After init the value reflects what's persisted (default). + // First-run default is computed by the EnumPref; it is one of the four + // ThemeMode values regardless of whether we're on TV or not. + // Wait a microtask for _initializeSettings() to finish. + await Future.delayed(Duration.zero); + expect(settings.ThemeMode.values, contains(p.themeMode)); + + p.dispose(); + }); + + test('setThemeMode persists, notifies, and is a no-op for same value', () async { + final p = ThemeProvider(); + await Future.delayed(Duration.zero); + + var notified = 0; + p.addListener(() => notified++); + + // Pick a mode that is guaranteed different from default `system`/`oled` + // on either pathway: cycle through values until we hit one that differs. + final initial = p.themeMode; + final next = settings.ThemeMode.values.firstWhere((m) => m != initial); + await p.setThemeMode(next); + 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); + + p.dispose(); + }); + + test('materialThemeMode maps each ThemeMode value correctly', () async { + final p = ThemeProvider(); + await Future.delayed(Duration.zero); + + await p.setThemeMode(settings.ThemeMode.light); + expect(p.materialThemeMode, material.ThemeMode.light); + + await p.setThemeMode(settings.ThemeMode.dark); + expect(p.materialThemeMode, material.ThemeMode.dark); + + await p.setThemeMode(settings.ThemeMode.oled); + expect(p.materialThemeMode, material.ThemeMode.dark); + + await p.setThemeMode(settings.ThemeMode.system); + expect(p.materialThemeMode, material.ThemeMode.system); + + p.dispose(); + }); + + test('isDarkMode reflects mode for explicit settings', () async { + final p = ThemeProvider(); + await Future.delayed(Duration.zero); + + await p.setThemeMode(settings.ThemeMode.light); + expect(p.isDarkMode, isFalse); + + await p.setThemeMode(settings.ThemeMode.dark); + expect(p.isDarkMode, isTrue); + + await p.setThemeMode(settings.ThemeMode.oled); + expect(p.isDarkMode, isTrue); + + p.dispose(); + }); + + test('darkTheme variants differ between dark and oled', () async { + final p = ThemeProvider(); + await Future.delayed(Duration.zero); + + await p.setThemeMode(settings.ThemeMode.dark); + final dark = p.darkTheme; + await p.setThemeMode(settings.ThemeMode.oled); + final oled = p.darkTheme; + + // OLED must be a true black canvas; regular dark uses a non-black surface. + expect(oled.scaffoldBackgroundColor, const material.Color(0xFF000000)); + expect(dark.scaffoldBackgroundColor, isNot(const material.Color(0xFF000000))); + + p.dispose(); + }); + + test('themeModeDisplayName and themeModeIcon match the active mode', () async { + final p = ThemeProvider(); + await Future.delayed(Duration.zero); + + await p.setThemeMode(settings.ThemeMode.light); + expect(p.themeModeDisplayName, 'Light'); + expect(p.themeModeIcon, Symbols.light_mode_rounded); + + await p.setThemeMode(settings.ThemeMode.dark); + expect(p.themeModeDisplayName, 'Dark'); + expect(p.themeModeIcon, Symbols.dark_mode_rounded); + + await p.setThemeMode(settings.ThemeMode.oled); + expect(p.themeModeDisplayName, 'OLED'); + expect(p.themeModeIcon, Symbols.contrast_rounded); + + await p.setThemeMode(settings.ThemeMode.system); + expect(p.themeModeDisplayName, 'System'); + expect(p.themeModeIcon, Symbols.brightness_auto_rounded); + + p.dispose(); + }); + + test('reload re-reads after external mutation', () async { + final p = ThemeProvider(); + await Future.delayed(Duration.zero); + + // Mutate the persisted theme out-of-band. + final svc = await settings.SettingsService.getInstance(); + await svc.write(settings.SettingsService.themeMode, settings.ThemeMode.light); + + var notified = 0; + p.addListener(() => notified++); + + await p.reload(); + expect(p.themeMode, settings.ThemeMode.light); + expect(notified, 1); + + p.dispose(); + }); + + test('persists across provider instances via SharedPreferences', () async { + final first = ThemeProvider(); + await Future.delayed(Duration.zero); + await first.setThemeMode(settings.ThemeMode.dark); + first.dispose(); + + // Reset only the cached singleton — backing store is preserved. + BaseSharedPreferencesService.resetForTesting(); + + final second = ThemeProvider(); + await Future.delayed(Duration.zero); + expect(second.themeMode, settings.ThemeMode.dark); + second.dispose(); + }); + + test('safeNotifyListeners no-ops after dispose', () async { + final p = ThemeProvider(); + await Future.delayed(Duration.zero); + p.dispose(); + // Should not throw — reload calls safeNotifyListeners under the hood. + await p.reload(); + }); + }); +} diff --git a/test/providers/trackers_provider_test.dart b/test/providers/trackers_provider_test.dart new file mode 100644 index 00000000..a6d21da4 --- /dev/null +++ b/test/providers/trackers_provider_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/providers/trackers_provider.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/trackers/anilist/anilist_account_store.dart'; +import 'package:plezy/services/trackers/anilist/anilist_session.dart'; +import 'package:plezy/services/trackers/mal/mal_account_store.dart'; +import 'package:plezy/services/trackers/mal/mal_session.dart'; +import 'package:plezy/services/trackers/simkl/simkl_account_store.dart'; +import 'package:plezy/services/trackers/simkl/simkl_session.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; + +import '../test_helpers/prefs.dart'; + +MalSession _mal({String? username}) => MalSession( + accessToken: 'mal-at', + refreshToken: 'mal-rt', + expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + username: username, +); + +AnilistSession _anilist({String? username}) => AnilistSession( + accessToken: 'anilist-at', + expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + username: username, +); + +SimklSession _simkl({String? username}) => + SimklSession(accessToken: 'simkl-at', createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, username: username); + +void main() { + setUp(resetSharedPreferencesForTest); + + group('TrackersProvider', () { + test('starts with all trackers disconnected', () { + final p = TrackersProvider(); + expect(p.mal, isNull); + expect(p.anilist, isNull); + expect(p.simkl, isNull); + expect(p.isMalConnected, isFalse); + expect(p.isAnilistConnected, isFalse); + expect(p.isSimklConnected, isFalse); + expect(p.malUsername, isNull); + expect(p.anilistUsername, isNull); + expect(p.simklUsername, isNull); + expect(p.isConnecting(TrackerService.mal), isFalse); + expect(p.isConnecting(TrackerService.anilist), isFalse); + expect(p.isConnecting(TrackerService.simkl), isFalse); + p.dispose(); + }); + + test('onActiveProfileChanged loads sessions from per-profile stores', () async { + const uuid = 'profile-1'; + await malAccountStore.save(uuid, _mal(username: 'alice')); + await anilistAccountStore.save(uuid, _anilist(username: 'bob')); + await simklAccountStore.save(uuid, _simkl(username: 'carol')); + + // Reset cached singletons so the provider reads fresh prefs state. + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + var notified = 0; + p.addListener(() => notified++); + + await p.onActiveProfileChanged(uuid); + expect(p.isMalConnected, isTrue); + expect(p.isAnilistConnected, isTrue); + expect(p.isSimklConnected, isTrue); + expect(p.malUsername, 'alice'); + expect(p.anilistUsername, 'bob'); + expect(p.simklUsername, 'carol'); + expect(notified, greaterThanOrEqualTo(1)); + + p.dispose(); + }); + + test('onActiveProfileChanged switching to empty profile clears all sessions', () async { + const uuid = 'profile-1'; + await malAccountStore.save(uuid, _mal(username: 'alice')); + await anilistAccountStore.save(uuid, _anilist(username: 'bob')); + await simklAccountStore.save(uuid, _simkl(username: 'carol')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await p.onActiveProfileChanged(uuid); + expect(p.isMalConnected, isTrue); + + await p.onActiveProfileChanged('other-profile'); + expect(p.isMalConnected, isFalse); + expect(p.isAnilistConnected, isFalse); + expect(p.isSimklConnected, isFalse); + + p.dispose(); + }); + + test('onActiveProfileChanged loads only the populated stores', () async { + const uuid = 'profile-2'; + // Only AniList is set up — MAL and Simkl remain absent. + await anilistAccountStore.save(uuid, _anilist(username: 'bob')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await p.onActiveProfileChanged(uuid); + expect(p.isAnilistConnected, isTrue); + expect(p.anilistUsername, 'bob'); + expect(p.isMalConnected, isFalse); + expect(p.isSimklConnected, isFalse); + p.dispose(); + }); + + test('disconnectMal clears stored session and notifies', () async { + const uuid = 'profile-3'; + await malAccountStore.save(uuid, _mal(username: 'alice')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await p.onActiveProfileChanged(uuid); + expect(p.isMalConnected, isTrue); + + var notified = 0; + p.addListener(() => notified++); + + await p.disconnectMal(); + expect(p.isMalConnected, isFalse); + expect(p.mal, isNull); + // _clearAndRebind notifies once. + expect(notified, 1); + + // Persistence is cleared too. + expect(await malAccountStore.load(uuid), isNull); + + p.dispose(); + }); + + test('disconnectAnilist clears anilist while leaving MAL intact', () async { + const uuid = 'profile-4'; + await malAccountStore.save(uuid, _mal(username: 'alice')); + await anilistAccountStore.save(uuid, _anilist(username: 'bob')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await p.onActiveProfileChanged(uuid); + + await p.disconnectAnilist(); + expect(p.isAnilistConnected, isFalse); + expect(p.isMalConnected, isTrue); + expect(p.malUsername, 'alice'); + + p.dispose(); + }); + + test('disconnectSimkl on a profile with no session is safe', () async { + final p = TrackersProvider(); + // No `onActiveProfileChanged` — uuid is empty (global slot). + // disconnectSimkl just clears the (already absent) entry and rebinds. + await p.disconnectSimkl(); + expect(p.isSimklConnected, isFalse); + p.dispose(); + }); + + test('cancelConnect is a no-op when not connecting', () { + final p = TrackersProvider(); + expect(() => p.cancelConnect(), returnsNormally); + expect(p.isConnecting(TrackerService.mal), isFalse); + p.dispose(); + }); + + test('safeNotifyListeners after dispose is a no-op', () async { + final p = TrackersProvider(); + p.dispose(); + // Post-dispose rebind should not throw. + await p.onActiveProfileChanged('any-uuid'); + }); + }); +} diff --git a/test/providers/trakt_account_provider_test.dart b/test/providers/trakt_account_provider_test.dart new file mode 100644 index 00000000..a145ab97 --- /dev/null +++ b/test/providers/trakt_account_provider_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/providers/trakt_account_provider.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/trakt/trakt_account_store.dart'; +import 'package:plezy/services/trakt/trakt_session.dart'; + +import '../test_helpers/prefs.dart'; + +TraktSession _session({String? username, String accessToken = 'at', String refreshToken = 'rt'}) { + return TraktSession( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + scope: 'public', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + username: username, + ); +} + +void main() { + setUp(resetSharedPreferencesForTest); + + group('TraktAccountProvider', () { + test('starts disconnected with null session', () { + final p = TraktAccountProvider(); + expect(p.session, isNull); + expect(p.isConnected, isFalse); + expect(p.username, isNull); + expect(p.isConnecting, isFalse); + p.dispose(); + }); + + test('onActiveProfileChanged loads stored session and notifies', () async { + // Pre-seed the store for a specific profile uuid. + const uuid = 'profile-1'; + await traktAccountStore.save(uuid, _session(username: 'alice')); + + // Reset cached singletons so the provider reads fresh prefs state. + BaseSharedPreferencesService.resetForTesting(); + + final p = TraktAccountProvider(); + var notified = 0; + p.addListener(() => notified++); + + await p.onActiveProfileChanged(uuid); + expect(p.isConnected, isTrue); + expect(p.username, 'alice'); + expect(p.session?.accessToken, 'at'); + // _setSessionAndRebind notifies once. + expect(notified, greaterThanOrEqualTo(1)); + + p.dispose(); + }); + + test('onActiveProfileChanged with unknown uuid clears session', () async { + const uuid = 'profile-1'; + await traktAccountStore.save(uuid, _session(username: 'alice')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TraktAccountProvider(); + await p.onActiveProfileChanged(uuid); + expect(p.isConnected, isTrue); + + // Switch to a profile with no stored session. + await p.onActiveProfileChanged('other-profile'); + expect(p.isConnected, isFalse); + expect(p.username, isNull); + + p.dispose(); + }); + + test('onActiveProfileChanged with null uuid loads from empty/global slot', () async { + final p = TraktAccountProvider(); + await p.onActiveProfileChanged(null); + expect(p.isConnected, isFalse); + p.dispose(); + }); + + test('disconnect with no session clears state and notifies', () async { + final p = TraktAccountProvider(); + var notified = 0; + p.addListener(() => notified++); + + await p.disconnect(); + expect(p.isConnected, isFalse); + expect(p.session, isNull); + // _setSessionAndRebind always notifies. + expect(notified, 1); + + p.dispose(); + }); + + test('cancelConnect is a no-op when not connecting', () { + final p = TraktAccountProvider(); + // Should not throw when no completer exists. + expect(() => p.cancelConnect(), returnsNormally); + expect(p.isConnecting, isFalse); + p.dispose(); + }); + + test('safeNotifyListeners after dispose is a no-op', () async { + final p = TraktAccountProvider(); + p.dispose(); + // After dispose, calling onActiveProfileChanged still runs the rebind + // path; safeNotifyListeners must swallow the post-dispose notification + // without throwing. + await p.onActiveProfileChanged('any-uuid'); + }); + }); +}