import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:video_player/video_player.dart'; // ignore: depend_on_referenced_packages import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/models/download_license.dart'; import 'package:fruitshorts/core/data/models/download_policy.dart'; import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/data/services/wallet_controller.dart'; import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import '../support/fake_watch_history.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; Drama _drama( String id, { List numbers = const [1, 2, 3], bool isFree = true, }) => Drama( id: id, title: id, synopsis: 'synopsis', posterUrl: '/files/poster.png', episodes: [ for (final number in numbers) Episode( id: '$id-$number', number: number, title: 'Episode $number', // 与后端一致:只有可播放的剧集才带地址,锁定集为空 videoUrl: isFree ? 'https://example.test/clip.mp4' : '', isFree: isFree, ), ], ); Drama _gateDrama() => const Drama( id: 'gate', title: 'Gate', synopsis: 'Gate synopsis', posterUrl: '/files/gate.png', episodes: [ Episode( id: 'gate-1', number: 1, title: 'Free', isFree: true, videoUrl: 'https://example.test/gate-1.mp4', ), Episode( id: 'gate-2', number: 2, title: 'Locked', isFree: false, locked: true, videoUrl: 'https://example.test/gate-2.mp4', ), Episode( id: 'gate-3', number: 3, title: 'Next', isFree: true, videoUrl: 'https://example.test/gate-3.mp4', ), ], ); AndroidPictureInPicture _pip() => AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip')); PlayerLogic _logic( FakeWatchHistory history, { PlaybackRepository? playback, PlaybackPositionCache? positionCache, WalletController? wallet, Future Function()? waitForFrame, }) => PlayerLogic( history: history, playback: playback ?? FakePlaybackRepository(), pictureInPicture: _pip(), wallet: wallet, positionCache: positionCache, waitForFrame: waitForFrame ?? () async {}, ); /// Drains the session's serialized work so a test never ends mid-disposal. /// /// `start` kicks off selection without awaiting it, so the first read of /// `playbackSettled` can still be the untouched initial future. Pumping the /// event queue between reads closes that race. Future _drain(PlayerLogic logic) async { for (var attempt = 0; attempt < 4; attempt++) { await pumpEventQueue(); await logic.session.playbackSettled; await logic.session.preloadSettled; } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('displayEpisode', () { test('should_fall_back_until_episode_number_is_picked_for_that_drama_when_resolving', () { final logic = _logic(FakeWatchHistory()); final drama = _drama('signal'); final fallback = drama.episodes.first; expect(logic.displayEpisode(drama, fallback).number, 1); PlayerLogic.pickedEpisode['signal'] = 3; expect(logic.displayEpisode(drama, fallback).number, 3); // Picks are scoped to their own drama. final city = _drama('city'); expect(logic.displayEpisode(city, city.episodes.first).number, 1); }); test('should_stop_overriding_fallback_when_picked_number_is_unknown', () { final logic = _logic(FakeWatchHistory()); final drama = _drama('signal'); final fallback = drama.episodes.first; PlayerLogic.pickedEpisode['signal'] = 99; expect(logic.displayEpisode(drama, fallback).number, 1); }); }); group('nextVisibleIndex', () { test('should_walk_visible_list_and_stop_at_the_end_when_advancing', () { final logic = _logic(FakeWatchHistory()); logic.visibleIndices = [0, 2, 4]; expect(logic.nextVisibleIndex(0), 2); expect(logic.nextVisibleIndex(2), 4); expect(logic.nextVisibleIndex(4), isNull); }); test('should_have_no_successor_when_index_is_outside_visible_list', () { final logic = _logic(FakeWatchHistory()); logic.visibleIndices = [0, 2, 4]; expect(logic.nextVisibleIndex(1), isNull); expect(logic.nextVisibleIndex(9), isNull); expect(logic.nextVisibleIndex(-1), isNull); }); test('should_have_no_successor_when_visible_list_is_empty', () { final logic = _logic(FakeWatchHistory()); logic.visibleIndices = []; expect(logic.nextVisibleIndex(0), isNull); }); }); group('playback position cache', () { test('should_use_cached_resume_point_without_reading_history', () async { final history = FakeWatchHistory()..saved = const Duration(seconds: 12); final cache = PlaybackPositionCache(); cache.save( episodeId: 'signal-1', dramaId: 'signal', position: const Duration(minutes: 1, seconds: 20), duration: const Duration(minutes: 3), ); final logic = _logic(history, positionCache: cache); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), isMounted: () => true, ); logic.start( factory: _RecordingFactory().create, coordinator: PlaybackCoordinator(), ); await _drain(logic); await pumpEventQueue(); expect(logic.resumePoint?.position, const Duration(seconds: 80)); expect(history.reads, isEmpty); logic.dispose(); await _drain(logic); }); test('should_fall_back_to_server_when_cache_belongs_to_other_drama', () async { final history = FakeWatchHistory() ..records['signal'] = const WatchProgress( dramaId: 'signal', episodeId: 'signal-2', position: Duration(seconds: 42), duration: Duration(minutes: 3), ); final cache = PlaybackPositionCache(); cache.save( episodeId: 'signal-1', dramaId: 'other-drama', position: const Duration(seconds: 80), duration: const Duration(minutes: 3), ); final logic = _logic(history, positionCache: cache); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), isMounted: () => true, ); logic.start( factory: _RecordingFactory().create, coordinator: PlaybackCoordinator(), ); await _drain(logic); await pumpEventQueue(); expect(logic.resumePoint?.episodeId, 'signal-2'); expect(logic.resumePoint?.position, const Duration(seconds: 42)); expect(history.reads, ['signal']); logic.dispose(); await _drain(logic); }); test('should_fall_back_to_server_when_cache_is_missing', () async { final history = FakeWatchHistory() ..records['signal'] = const WatchProgress( dramaId: 'signal', episodeId: 'signal-1', position: Duration(seconds: 36), duration: Duration(minutes: 3), ); final logic = _logic(history); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), isMounted: () => true, ); logic.start( factory: _RecordingFactory().create, coordinator: PlaybackCoordinator(), ); await _drain(logic); await pumpEventQueue(); expect(logic.resumePoint?.position, const Duration(seconds: 36)); expect(history.reads, ['signal']); logic.dispose(); await _drain(logic); }); testWidgets( 'should_cache_actual_position_after_seek_completes', (tester) async { late BuildContext context; await tester.pumpWidget( MaterialApp( home: Builder( builder: (pageContext) { context = pageContext; return const Scaffold(body: SizedBox.expand()); }, ), ), ); final cache = PlaybackPositionCache(); final history = FakeWatchHistory(); final factory = _VideoRecordingFactory(); late PlayerLogic logic; await tester.runAsync(() async { logic = _logic(history, positionCache: cache); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0), isMounted: () => true, ); logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); }); final player = logic.session.active! as _VideoRecordingPlayer; logic.beginScrub(player); await tester.runAsync( () => logic.seekTo( context, player, const Duration(seconds: 42), ), ); await tester.runAsync(() => logic.pendingWrite); expect( cache.read('signal-1', dramaId: 'signal')?.position, const Duration(seconds: 42), ); expect(history.writes.last, 'signal-1|42000|false'); logic.beginScrub(player); await tester.runAsync( () => logic.seekTo( context, player, const Duration(minutes: 2), ), ); await tester.runAsync(() => logic.pendingWrite); expect(player.controller.value.position, const Duration(minutes: 1)); expect( cache.read('signal-1', dramaId: 'signal')?.position, const Duration(minutes: 1), ); expect(history.writes.last, 'signal-1|60000|false'); logic.dispose(); await tester.pump(); }, ); testWidgets('should_seek_cached_position_before_play_when_rebuilding', ( tester, ) async { final previousPlatform = VideoPlayerPlatform.instance; VideoPlayerPlatform.instance = _VideoPlayerPlatformStub(); addTearDown(() => VideoPlayerPlatform.instance = previousPlatform); final cache = PlaybackPositionCache(); cache.save( episodeId: 'signal-1', dramaId: 'signal', position: const Duration(seconds: 42), duration: const Duration(minutes: 1), ); final factory = _VideoRecordingFactory(); late PlayerLogic logic; await tester.runAsync(() async { logic = _logic(FakeWatchHistory(), positionCache: cache); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0), isMounted: () => true, ); logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); await pumpEventQueue(); await logic.session.playbackSettled; await logic.session.preloadSettled; }); final player = logic.session.active! as _VideoRecordingPlayer; expect(player.events, containsAllInOrder(['initialize', 'seek', 'play'])); expect(player.controller.value.position, const Duration(seconds: 42)); logic.dispose(); await tester.pump(); }); }); test('should_play_local_file_without_ticket_when_episode_is_downloaded', () async { final playback = FakePlaybackRepository(); final downloads = DownloadController( _EmptyDownloadRepository(), storage: _LocalStorage(), ); await downloads.restore(); final drama = _drama('signal'); final logic = PlayerLogic( history: FakeWatchHistory(), playback: playback, pictureInPicture: _pip(), downloads: downloads, ); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start(factory: _RecordingFactory().create, coordinator: PlaybackCoordinator()); await pumpEventQueue(); await logic.session.playbackSettled; expect(playback.requests, isEmpty); expect(logic.sessionEpisodes.first.videoUrl, 'file:///local/signal-1.mp4'); }); test('should_write_ticket_poster_url_into_session_episode', () async { final logic = _logic( FakeWatchHistory(), playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'signal-1', accessible: true, accessType: PlayTicket.free, playUrl: '/files/signal-1.mp4', posterUrl: '/files/transcoded/signal-1/poster.jpg', ), ), ); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0), isMounted: () => true, ); logic.start(factory: _RecordingFactory().create, coordinator: PlaybackCoordinator()); await _drain(logic); expect( logic.sessionEpisodes.first.posterUrl, '/files/transcoded/signal-1/poster.jpg', ); logic.dispose(); await _drain(logic); }); test('should_coalesce_rapid_page_selection_and_keep_pool_bounded', () async { final factory = _RecordingFactory(); final playback = FakePlaybackRepository(); final logic = _logic(FakeWatchHistory(), playback: playback); logic.attach( PlayerPage( drama: _drama('rapid', numbers: const [1, 2, 3, 4, 5, 6]), initialIndex: 0, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); await Future.wait([ for (var index = 1; index <= 5; index++) logic.requestEpisodeSelection(index), ]); await _drain(logic); expect(logic.session.currentIndex, 5); expect(playback.requests, containsAll(['rapid-1', 'rapid-2', 'rapid-6'])); expect(playback.requests, isNot(contains('rapid-3'))); expect(playback.requests, isNot(contains('rapid-4'))); expect(playback.requests, isNot(contains('rapid-5'))); expect(logic.session.livePlayerCount, lessThanOrEqualTo(3)); expect(factory.live, lessThanOrEqualTo(3)); await logic.requestEpisodeSelection(0); await _drain(logic); expect(logic.session.currentIndex, 0); logic.dispose(); await _drain(logic); }); group('surface flags', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); final immersive = []; logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.onImmersiveChanged = immersive.add; logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); logic.onPipChanged(true); expect(logic.pip, isTrue); expect(logic.pipPreparing, isFalse); expect(logic.immersive, isTrue); expect(logic.chromeHidden, isTrue); expect(immersive.last, isTrue); logic.onPipChanged(false); expect(logic.pip, isFalse); expect(logic.immersive, isFalse); logic.dispose(); await _drain(logic); }); test('should_ignore_picture_in_picture_callback_when_unmounted', () { final logic = _logic(FakeWatchHistory()); logic.isMounted = () => false; logic.onPipChanged(true); expect(logic.pip, isFalse); }); test('should_clear_stale_pip_when_app_returns_to_foreground', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); final immersive = []; logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.onImmersiveChanged = immersive.add; logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); // 模拟画中画已进入但退出时没有回调 false。 logic.onPipChanged(true); expect(logic.chromeHidden, isTrue); logic.onLifecycle(AppLifecycleState.resumed); expect(logic.pip, isFalse); expect(logic.pipPreparing, isFalse); expect(logic.chromeHidden, isFalse); expect(immersive.last, isFalse); logic.dispose(); await _drain(logic); }); test('should_resume_playback_after_seek_when_paused', () async { final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0), isMounted: () => true, ); logic.start( factory: _RecordingFactory().create, coordinator: PlaybackCoordinator(), ); await _drain(logic); logic.session.setUserWantsPlay(false); expect(logic.session.userWantsPlay, isFalse); logic.resumeAfterSeek(); expect(logic.session.userWantsPlay, isTrue); logic.dispose(); await _drain(logic); }); }); group('history writes', () { test('should_serialize_progress_writes_in_call_order_when_saving', () async { final history = FakeWatchHistory(); final logic = _logic(history); final drama = _drama('signal'); await logic.queueProgress( drama, drama.episodes[0], const Duration(seconds: 1), ); await logic.queueProgress( drama, drama.episodes[1], const Duration(seconds: 2), ); expect(history.writes, ['signal-1|1000|false', 'signal-2|2000|false']); }); test('should_keep_later_writes_when_an_earlier_write_fails', () async { final history = FakeWatchHistory(); final logic = _logic(history); final drama = _drama('signal'); history.failWrites = true; await logic.queueProgress( drama, drama.episodes[0], const Duration(seconds: 1), ); history.failWrites = false; await logic.queueProgress( drama, drama.episodes[1], const Duration(seconds: 2), ); expect(history.writes, ['signal-2|2000|false']); }); test('should_write_nothing_when_no_player_is_tracked', () async { final history = FakeWatchHistory(); final logic = _logic(history); await logic.saveCurrentProgress(); await logic.saveCurrentProgress(); expect(history.writes, isEmpty); expect(history.reads, isEmpty); }); }); group('start', () { test('should_play_single_series_when_route_is_detail', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); expect(logic.queue.length, 3); expect(logic.visibleIndices, [0, 1, 2]); expect(logic.session.currentIndex, 0); expect(factory.created, hasLength(2)); expect(factory.created.first.plays, 1); logic.dispose(); await _drain(logic); expect(factory.live, 0); }); test('should_play_one_episode_per_feed_drama_when_in_home_mode', () async { final factory = _RecordingFactory(); final feed = [_drama('signal'), _drama('city'), _drama('vengeance')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: feed.first, initialIndex: 0, homeMode: true, feedDramas: feed, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); expect(logic.queue.length, 3); expect(logic.queue.entryAt(1).drama.id, 'city'); expect(logic.queue.entryAt(1).episode.number, 1); expect(logic.visibleIndices, [0, 1, 2]); logic.dispose(); await _drain(logic); }); test('should_never_reach_player_factory_when_episode_is_locked', () async { final factory = _RecordingFactory(); final drama = _drama('locked', isFree: false); // 锁定的判定权在后端:票据返回不可播 + 无地址,客户端不得自行推断 final logic = _logic( FakeWatchHistory(), playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'locked-1', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, ), ), ); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); expect(logic.session.isLocked, isTrue); expect(factory.created, isEmpty); logic.dispose(); await _drain(logic); }); test('should_pause_and_release_when_swiping_to_locked_episode', () async { final factory = _RecordingFactory(); final playback = FakePlaybackRepository(); final logic = _logic(FakeWatchHistory(), playback: playback); logic.attach( PlayerPage(drama: _gateDrama(), initialIndex: 0), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); final previous = factory.created.single; playback.ticket = const PlayTicket( episodeId: 'gate-2', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, ); await logic.selectEpisode(1); await _drain(logic); expect(previous.pauses, greaterThan(0)); expect(previous.disposed, isTrue); expect(logic.session.active, isNull); expect(factory.live, 0); expect(factory.created, hasLength(1)); expect(logic.blockedEpisodeId, 'gate-2'); playback.ticket = null; await logic.selectEpisode(2); await _drain(logic); expect(logic.session.active, isNotNull); expect(factory.created.last.plays, greaterThan(0)); logic.dispose(); await _drain(logic); }); test('should_start_locked_episode_after_unlock_succeeds', () async { final factory = _RecordingFactory(); final playback = FakePlaybackRepository(); final wallet = WalletController(_UnlockCommerceRepository()); final logic = _logic( FakeWatchHistory(), playback: playback, wallet: wallet, ); logic.attach( PlayerPage(drama: _gateDrama(), initialIndex: 0), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); playback.ticket = const PlayTicket( episodeId: 'gate-2', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, ); await logic.selectEpisode(1); await _drain(logic); expect(logic.session.active, isNull); playback.ticket = const PlayTicket( episodeId: 'gate-2', accessible: true, accessType: PlayTicket.coinRequired, playUrl: '/files/gate-2.mp4', ); final unlocked = await logic.unlockEpisode(1); await _drain(logic); expect(unlocked, isTrue); expect(logic.isEpisodeLocked(logic.sessionEpisodes[1]), isFalse); final active = logic.session.active; expect(active, isA<_RecordingPlayer>()); expect((active! as _RecordingPlayer).plays, greaterThan(0)); logic.dispose(); await _drain(logic); }); test('should_preload_only_what_follows_when_starting_from_a_later_index', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: drama, initialIndex: 2), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); expect(logic.session.currentIndex, 2); // 最后一集没有下一集,但仍会预热上一集。 expect(factory.created, hasLength(2)); expect(logic.session.preparedPlayerAt(1), isNotNull); logic.dispose(); await _drain(logic); }); test('should_hold_no_players_when_route_is_inactive', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: drama, initialIndex: 0, active: false), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); expect(factory.created, isEmpty); expect(logic.session.loading, isFalse); logic.dispose(); await _drain(logic); }); }); group('updateFeed', () { test('should_append_feed_without_restarting_current_drama', () async { final factory = _RecordingFactory(); final feed = [_drama('signal'), _drama('city')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: feed.first, initialIndex: 0, homeMode: true, feedDramas: feed, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); final activeBefore = logic.session.active; logic.updateFeed([...feed, _drama('third')], keepDramaId: 'signal'); await logic.feedUpdateSettled; expect(logic.currentDramaId, 'signal'); expect(logic.visibleIndices, [0, 1, 2]); expect(logic.sessionEpisodes, hasLength(3)); expect(logic.session.active, same(activeBefore)); logic.dispose(); await _drain(logic); }); test('should_remap_current_drama_without_restarting_when_order_changes', () async { final factory = _RecordingFactory(); final feed = [_drama('signal'), _drama('city')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: feed.first, initialIndex: 1, homeMode: true, feedDramas: feed, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); final activeBefore = logic.session.active; logic.updateFeed( [_drama('city'), _drama('signal'), _drama('third')], keepDramaId: 'city', ); await logic.feedUpdateSettled; expect(logic.currentDramaId, 'city'); expect(logic.session.currentIndex, 0); expect(logic.session.active, same(activeBefore)); logic.dispose(); await _drain(logic); }); test('should_return_to_first_drama_when_feed_channel_changes', () async { final factory = _RecordingFactory(); final feed = [_drama('signal'), _drama('city')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: feed.first, initialIndex: 0, homeMode: true, feedDramas: feed, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); logic.updateFeed( [_drama('anime-a'), _drama('anime-b')], resetToFirst: true, ); await logic.feedUpdateSettled; await _drain(logic); expect(logic.queue.entryAt(0).drama.id, 'anime-a'); expect(logic.currentDramaId, 'anime-a'); expect(logic.session.currentIndex, 0); logic.dispose(); await _drain(logic); }); test('should_clear_queue_safely_when_feed_becomes_empty', () async { final feed = [_drama('signal')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: feed.first, initialIndex: 0, homeMode: true, feedDramas: feed, ), isMounted: () => true, ); logic.start( factory: _RecordingFactory().create, coordinator: PlaybackCoordinator(), ); await _drain(logic); logic.updateFeed(const []); await logic.feedUpdateSettled; expect(logic.queue.length, 0); expect(logic.visibleIndices, isEmpty); expect(logic.currentDramaId, isNull); logic.dispose(); await _drain(logic); }); test('should_apply_only_latest_feed_when_update_is_called_five_times', () async { final factory = _RecordingFactory(); final logic = _logic(FakeWatchHistory()); final initial = [_drama('initial')]; logic.attach( PlayerPage( drama: initial.first, initialIndex: 0, homeMode: true, feedDramas: initial, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); for (var index = 1; index <= 5; index++) { logic.updateFeed([_drama('feed-$index')], resetToFirst: true); } await logic.feedUpdateSettled; await _drain(logic); expect(logic.feedRevision, 5); expect(logic.queue.entryAt(0).drama.id, 'feed-5'); expect(logic.sessionEpisodes.single.id, 'feed-5-1'); expect(logic.currentDramaId, 'feed-5'); logic.dispose(); await _drain(logic); }); test('should_select_first_item_when_feed_recovers_from_empty', () async { final factory = _RecordingFactory(); final feed = [_drama('signal')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: feed.first, initialIndex: 0, homeMode: true, feedDramas: feed, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); logic.updateFeed(const []); await logic.feedUpdateSettled; logic.updateFeed([_drama('restored')], resetToFirst: true); await logic.feedUpdateSettled; await _drain(logic); expect(logic.queue.entryAt(0).drama.id, 'restored'); expect(logic.session.currentIndex, 0); expect(logic.currentDramaId, 'restored'); expect(factory.live, greaterThan(0)); logic.dispose(); await _drain(logic); }); test('should_reset_to_first_when_channel_changes_with_same_ids', () async { final factory = _RecordingFactory(); final feed = [_drama('same-a'), _drama('same-b')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: feed.first, initialIndex: 1, homeMode: true, feedDramas: feed, ), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); expect(logic.session.currentIndex, 1); logic.updateFeed(feed, resetToFirst: true); await logic.feedUpdateSettled; await _drain(logic); expect(logic.session.currentIndex, 0); expect(logic.currentDramaId, 'same-a'); logic.dispose(); await _drain(logic); }); testWidgets( 'should_defer_feed_update_side_effects_when_called_during_build', (tester) async { final factory = _RecordingFactory(); final initial = [_drama('a'), _drama('b'), _drama('c')]; final latest = [_drama('d'), _drama('c'), _drama('a')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: initial.first, initialIndex: 0, homeMode: true, feedDramas: initial, ), isMounted: () => true, ); await tester.runAsync(() async { logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); }); expect(logic.currentDramaId, 'a'); expect(logic.session.active, isNotNull); var updateScheduled = false; await tester.pumpWidget( MaterialApp( home: ListenableBuilder( listenable: logic, builder: (context, _) { if (!updateScheduled) { updateScheduled = true; logic.updateFeed(latest, keepDramaId: 'a'); } return Scaffold( body: PageView.builder( controller: logic.pages, itemCount: logic.visibleIndices.length, itemBuilder: (_, index) => ColoredBox(color: Colors.black, key: ValueKey(index)), ), ); }, ), ), ); expect(tester.takeException(), isNull); expect(logic.pages.hasClients, isTrue); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(logic.queue.entryAt(2).drama.id, 'a'); expect(logic.pages.page, closeTo(2, 0.01)); logic.dispose(); }, ); testWidgets( 'should_unmount_old_controller_before_disposing_it_when_feed_changes', (tester) async { final factory = _VideoRecordingFactory(); final initial = [_drama('old')]; final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage( drama: initial.first, initialIndex: 0, homeMode: true, feedDramas: initial, ), isMounted: () => true, ); await tester.runAsync(() async { logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); }); final oldPlayer = factory.created.single; await tester.pumpWidget( MaterialApp(home: _SessionVideoSurface(logic: logic)), ); await tester.pump(); expect(find.byType(VideoPlayer), findsOneWidget); logic.updateFeed([_drama('new')], resetToFirst: true); for (var frame = 0; frame < 5; frame++) { await tester.pump(); await tester.runAsync(pumpEventQueue); } expect(tester.takeException(), isNull); expect(oldPlayer.disposed, isTrue); expect(factory.created.length, greaterThanOrEqualTo(2)); logic.dispose(); await tester.pump(); }, ); }); group('lifecycle', () { test( 'should_auto_resume_previously_paused_playback_when_app_returns', () async { final factory = _RecordingFactory(); final playback = FakePlaybackRepository(fresh: false); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory(), playback: playback); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); final player = factory.created.first; final requestsBeforeResume = playback.requests.length; logic ..trackedEpisode = drama.episodes.first ..trackedDrama = drama; logic.session.setUserWantsPlay(false); await _drain(logic); expect(logic.session.userWantsPlay, isFalse); expect(player.plays, 1); logic.onLifecycle(AppLifecycleState.paused); await _drain(logic); expect(logic.trackedEpisode, isNotNull); expect(logic.ticketError, isNull); expect(logic.blockedEpisodeId, isNull); expect(logic.unlockingEpisodeId, isNull); expect(logic.session.isLocked, isFalse); expect(logic.session.error, isNull); expect(logic.session.canResumeAfterBackground, isTrue); logic.onLifecycle(AppLifecycleState.resumed); await _drain(logic); expect(logic.session.userWantsPlay, isTrue); expect(player.plays, 2); expect(playback.requests.length, greaterThan(requestsBeforeResume)); logic.dispose(); await _drain(logic); }, ); test('should_not_auto_resume_locked_episode_when_app_returns', () async { final factory = _RecordingFactory(); final drama = _drama('locked', isFree: false); final logic = _logic( FakeWatchHistory(), playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'locked-1', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, ), ), ); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); logic.trackedEpisode = drama.episodes.first; logic.session.setUserWantsPlay(false); logic.onLifecycle(AppLifecycleState.paused); await _drain(logic); logic.onLifecycle(AppLifecycleState.resumed); await _drain(logic); expect(logic.session.userWantsPlay, isFalse); expect(factory.created, isEmpty); logic.dispose(); await _drain(logic); }); test( 'should_not_auto_resume_when_ticket_failure_or_overlay_is_active', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); final player = factory.created.first; logic.session.setUserWantsPlay(false); await _drain(logic); logic.ticketError = 'ticket failed'; logic.onLifecycle(AppLifecycleState.paused); await _drain(logic); logic.onLifecycle(AppLifecycleState.resumed); await _drain(logic); expect(logic.session.userWantsPlay, isFalse); expect(player.plays, 1); logic.ticketError = null; logic.session.setOverlayOpen(true); logic.onLifecycle(AppLifecycleState.paused); await _drain(logic); logic.onLifecycle(AppLifecycleState.resumed); await _drain(logic); expect(logic.session.userWantsPlay, isFalse); expect(player.plays, 1); logic.session.setOverlayOpen(false); logic.dispose(); await _drain(logic); }, ); test( 'should_save_progress_and_pause_when_app_enters_background', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final history = FakeWatchHistory(); final logic = _logic(history); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); final player = factory.created.first; final tracked = VideoPlayerPort(drama.episodes.first); tracked.controller.value = const VideoPlayerValue( duration: Duration(minutes: 3), position: Duration(seconds: 5), isInitialized: true, ); logic ..trackedPlayer = tracked ..trackedEpisode = drama.episodes.first ..trackedDrama = drama; logic.onLifecycle(AppLifecycleState.paused); await _drain(logic); await logic.pendingWrite; expect(player.pauses, 1); expect(history.writes, ['signal-1|5000|false']); logic.dispose(); await _drain(logic); await tracked.dispose(); }, ); test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); final player = factory.created.first; expect(player.plays, 1); expect(player.pauses, 0); logic.onLifecycle(AppLifecycleState.paused); await _drain(logic); expect(player.pauses, 1); logic.onLifecycle(AppLifecycleState.resumed); await _drain(logic); expect(player.plays, 2); logic.dispose(); await _drain(logic); }); test('should_stop_playing_without_losing_intent_when_route_becomes_inactive', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); final start = PlayerPage(drama: drama, initialIndex: 0); logic.attach(start, isMounted: () => true); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); final player = factory.created.first; expect(player.plays, 1); logic.onPageUpdated(start); await _drain(logic); expect(player.plays, 1); logic.attach( PlayerPage(drama: drama, initialIndex: 0, active: false), isMounted: () => true, ); logic.onPageUpdated(start); await _drain(logic); expect(player.pauses, 1); expect(logic.session.userWantsPlay, isTrue); logic.dispose(); await _drain(logic); }); }); group('dispose', () { test('should_release_every_player_when_dispose_runs_twice', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); expect(factory.live, greaterThan(0)); logic.dispose(); await _drain(logic); expect(factory.live, 0); expect(() => logic.dispose(), returnsNormally); }); test('should_disable_further_notifications_when_disposed', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); var notifications = 0; logic.addListener(() => notifications++); logic.dispose(); await _drain(logic); logic.notify(); expect(notifications, 0); }); }); } class _LocalStorage implements DownloadStorage { @override Future> restore() async => { 'signal-1': 'file:///local/signal-1.mp4', }; @override Future localPathFor(String episodeId) async => episodeId == 'signal-1' ? 'file:///local/signal-1.mp4' : null; @override Future download({ required String episodeId, required String url, DownloadProgress? onProgress, }) async => throw UnimplementedError(); @override Future delete(String episodeId) async {} @override Future clear() async {} } class _EmptyDownloadRepository implements DownloadRepository { @override Future fetchPolicy() async => const DownloadPolicy( maxPerDevice: 20, licenseDays: 30, wifiOnly: false, maxQuality: 'AUTO', maxStorageBytes: 0, used: 0, remaining: 20, expirePolicyText: '', ); @override Future> list() async => const []; @override Future grant(String episodeId, {String? quality}) async => throw UnimplementedError(); @override Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} @override Future refreshUrl(String episodeId) async => throw UnimplementedError(); @override Future reportProgress( String episodeId, { String? deviceId, String? status, int? progressPercent, int? downloadedBytes, int? totalBytes, String? errorMsg, }) async => throw UnimplementedError(); @override Future remove(String episodeId) async {} @override Future clear() async => 0; } /// Counts native-style allocations so leaks are visible to a test. class _RecordingFactory { final List<_RecordingPlayer> created = []; int live = 0; PlayerPort create(Episode episode) { live++; final player = _RecordingPlayer(onDispose: () => live--); created.add(player); return player; } } class _RecordingPlayer implements PlayerPort { _RecordingPlayer({required this.onDispose}); final void Function() onDispose; int plays = 0; int pauses = 0; bool disposed = false; @override Future initialize() async {} @override Future play() async => plays++; @override Future pause() async => pauses++; @override Future dispose() async { if (disposed) return; disposed = true; onDispose(); } } class _SessionVideoSurface extends StatelessWidget { const _SessionVideoSurface({required this.logic}); final PlayerLogic logic; @override Widget build(BuildContext context) { return ListenableBuilder( listenable: logic, builder: (context, _) => AnimatedBuilder( animation: logic.session, builder: (context, _) { final port = logic.session.active; final video = port is VideoPlayerPort ? port : null; return Scaffold( body: PlayerVideoSurface( controller: video?.controller, videoUrl: video?.controller.dataSource ?? '', theater: false, ), ); }, ), ); } } class _VideoRecordingFactory { final List<_VideoRecordingPlayer> created = []; final List events = []; PlayerPort create(Episode episode) { final player = _VideoRecordingPlayer(episode, events); created.add(player); return player; } } class _VideoPlayerPlatformStub extends VideoPlayerPlatform { @override Future init() async {} @override Future dispose(int playerId) async {} @override Future setVolume(int playerId, double volume) async {} @override Future setPlaybackSpeed(int playerId, double speed) async {} } class _VideoRecordingPlayer extends VideoPlayerPort { _VideoRecordingPlayer(super.episode, this.events); bool disposed = false; final List events; @override Future initialize() async { events.add('initialize'); controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), position: Duration(seconds: 1), isInitialized: true, ); } @override Future play() async => events.add('play'); @override Future pause() async => events.add('pause'); @override Future seekTo(Duration position) async { events.add('seek'); final current = controller.value; controller.value = VideoPlayerValue( duration: current.duration, size: current.size, position: position, isInitialized: current.isInitialized, isPlaying: current.isPlaying, ); } @override Future dispose() async { if (disposed) return; disposed = true; await controller.dispose(); } } class _UnlockCommerceRepository implements CommerceRepository { @override Future fetchWallet() async => const WalletSnapshot( wallet: CoinWallet( coinBalance: 100, totalRechargedCoin: 0, totalConsumedCoin: 0, totalRefundedCoin: 0, ), ); @override Future unlock(String episodeId) async => const UnlockResult( unlocked: true, accessType: PlayTicket.coinRequired, coinCost: 30, coinBalance: 70, ); }