import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:reelbox/data/api_service.dart'; import 'package:reelbox/data/models.dart'; import 'package:reelbox/state/app_state.dart'; import 'package:shared_preferences/shared_preferences.dart'; const movie = TitleRecord(id: 8, title: 'A film'); const episode = EpisodeRecord(id: 12, title: 'Part 1'); class LocalApi extends ApiService { @override Future initialize({bool discover = true}) async {} @override Future poster(int id) async => const TitleRecord( id: 8, title: 'Current film', image: 'https://example.org/current.jpg', sources: [MediaSource(id: 2, url: 'https://example.org/new.mp4')], ); @override Future> seasons(int id) async => const [ SeasonRecord( id: 1, episodes: [ EpisodeRecord( id: 12, title: 'Current episode', sources: [ MediaSource(id: 3, url: 'https://example.org/episode-new.mp4'), ], ), ], ), ]; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => SharedPreferences.setMockInitialValues({})); test('legacy migration filters blocked raw records before validation and persists safe state', () async { final store = await SharedPreferences.getInstance(); final legacy = { 'format': 'reelbox.library', 'version': 1, 'settings': {'locale': 'fa'}, 'saved': [ movie.toJson(), {'id': -1, 'type': 'channel'}, {'id': 0, 'title': 'Norton'}, ], 'history': [], 'searchHistory': [], 'collections': [ { 'id': 'private-id', 'name': 'Private name', 'titles': [ movie.toJson(), {'title': 'ملودی باکس'}, ], }, ], }; await store.setString(AppState.storageKey, jsonEncode(legacy)); final state = AppState(api: LocalApi()); await state.initialize(discover: false); expect(state.prefs.introCompleted, true); expect(state.saved.single.id, 8); expect(state.collections.single.id, 'private-id'); expect(state.collections.single.name, 'Private name'); expect(state.collections.single.titles.single.id, 8); final persisted = jsonDecode(store.getString(AppState.storageKey)!); expect(persisted['saved'].length, 1); expect(persisted['collections'][0]['titles'].length, 1); state.dispose(); }); test( 'free-form local labels and settings cannot surface excluded promotions', () async { final state = AppState(api: LocalApi()); await expectLater(state.createCollection('Norton'), throwsArgumentError); await state.updateSettings( state.prefs.copyWith( subtitleFontFamily: 'ملودی باکس', defaultQuality: 'https://t.me/example', ), ); expect(state.prefs.subtitleFontFamily, 'sans-serif'); expect(state.prefs.defaultQuality, 'ask'); final collection = await state.createCollection('Keep me'); await state.toggleCollectionTitle(collection.id, movie); final backup = jsonDecode(state.exportLibrary()) as Map; backup['collections'][0]['name'] = 'Norton'; await state.importLibrary(jsonEncode(backup)); expect(state.collections.single.name, 'Local collection'); expect(state.collections.single.titles.single.id, movie.id); state.dispose(); }, ); test('malformed backup and failed startup never reset an existing private library', () async { final state = AppState(api: LocalApi()); await state.toggleSaved(movie); final before = state.exportLibrary(); final malformed = jsonDecode(before) as Map; malformed['watchedEpisodes'] = [ {'title': movie.toJson(), 'episodeId': -1, 'episodeTitle': ''}, ]; await expectLater( state.importLibrary(jsonEncode(malformed)), throwsFormatException, ); expect(state.exportLibrary(), before); final store = await SharedPreferences.getInstance(); const corrupt = '{not valid JSON'; await store.setString(AppState.storageKey, corrupt); final restored = AppState(api: LocalApi()); await expectLater( restored.initialize(discover: false), throwsFormatException, ); expect(store.getString(AppState.storageKey), corrupt); state.dispose(); restored.dispose(); }); test( 'episode watched marker persists without fabricating playback checkpoints', () async { final state = AppState(api: LocalApi()); await state.setEpisodeWatched(movie, episode, true); expect(state.isEpisodeWatched(8, 12), true); expect(state.history, isEmpty); await state.recordWatch(movie, episodeId: 12, positionSeconds: 24); await state.setEpisodeWatched(movie, episode, false); expect(state.isEpisodeWatched(8, 12), false); expect(state.resumeSeconds(8, episodeId: 12), 24); await state.setEpisodeWatched(movie, episode, true); final restored = AppState(api: LocalApi()); await restored.initialize(discover: false); expect(restored.isEpisodeWatched(8, 12), true); final imported = AppState(api: LocalApi()); await imported.importLibrary(state.exportLibrary()); expect(imported.isEpisodeWatched(8, 12), true); state.dispose(); restored.dispose(); imported.dispose(); }, ); test( 'subtitle reset changes only subtitle fields and preserves local library', () async { final state = AppState(api: LocalApi()); await state.toggleSaved(movie); await state.createCollection('Private'); await state.updateSettings( state.prefs.copyWith( locale: 'fa', parallelDownloads: 7, subtitleFontSize: 60, subtitleColor: 0x12123456, subtitleDelay: 4, subtitleFontFamily: 'serif', subtitleEnabled: false, audioDelay: 2, ), ); await state.resetSubtitleStyle(); expect(state.prefs.subtitleFontSize, 24); expect(state.prefs.subtitleColor, 0xFFFFFFFF); expect(state.prefs.subtitleFontFamily, 'sans-serif'); expect(state.prefs.subtitleDelay, 0); expect(state.prefs.subtitleEnabled, true); expect(state.prefs.audioDelay, 2); expect(state.prefs.parallelDownloads, 7); expect(state.locale, 'fa'); expect(state.saved.single.id, 8); expect(state.collections.single.name, 'Private'); state.dispose(); }, ); test('missing current episode rejects continuation instead of playing an expired checkpoint URL', () async { final state = AppState(api: LocalApi()); await state.recordWatch( movie, episodeId: 404, sourceUrl: 'https://example.org/expired.mp4', positionSeconds: 99, ); await expectLater( state.resolveHistory(state.history.single), throwsStateError, ); expect(state.resumeSeconds(8, episodeId: 404), 99); state.dispose(); }); test( 'continuation resolves fresh poster and episode sources, never stale URL', () async { final state = AppState(api: LocalApi()); await state.recordWatch( movie, episodeId: 12, sourceUrl: 'https://example.org/expired.mp4', positionSeconds: 24, ); final continuation = await state.resolveHistory(state.history.single); expect(continuation.title.image, 'https://example.org/current.jpg'); expect(continuation.source.url, 'https://example.org/episode-new.mp4'); expect(continuation.episode?.id, 12); expect(continuation.playlist.single.id, 12); state.dispose(); }, ); test('legacy import retains private collections while removing blocked titles/channels', () async { final state = AppState(api: LocalApi()); await state.toggleSaved(movie); final collection = await state.createCollection('Private'); await state.toggleCollectionTitle(collection.id, movie); final backup = jsonDecode(state.exportLibrary()) as Map; backup['saved'].add( const TitleRecord(id: 9, type: 'channel', title: 'Channel').toJson(), ); backup['saved'].add( const TitleRecord(id: 10, title: 'ملودی باکس').toJson(), ); backup['collections'][0]['titles'].add( const TitleRecord(id: 11, title: 'Norton').toJson(), ); await state.importLibrary(jsonEncode(backup)); expect(state.saved.map((t) => t.id), [8]); expect(state.collections.single.titles.map((t) => t.id), [8]); await state.toggleSaved(const TitleRecord(id: 10, title: 'ملودی باکس')); expect(state.saved.length, 1); state.dispose(); }); }