// Author: fruit.com.cn import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; void main() { final episodes = List.generate( 4, (i) => Episode( id: 'ep-${i + 1}', number: i + 1, title: 'Episode ${i + 1}', videoUrl: 'https://example.test/episode_0${i + 1}.mp4', isFree: i < 2, ), ); test('should_create_no_player_when_episode_is_locked', () async { final factory = FakeFactory(); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, ); await session.select(2); expect(session.isLocked, isTrue); expect(factory.created, isEmpty); await session.close(); }); test('should_not_retire_home_controller_when_player_switches_episodes', () async { final factory = FakeFactory(); final home = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, owner: PlaybackSessionOwner.home, ); await home.select(0, preloadIndex: 1); await home.preloadSettled; final homePlayer = home.active; expect(homePlayer, isNotNull); final player = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, owner: PlaybackSessionOwner.player, ); await player.select(0, preloadIndex: 1); await player.select(1, previousIndex: 0); await player.preloadSettled; expect(home.active, same(homePlayer)); expect( factory.created .firstWhere((candidate) => identical(candidate, homePlayer)) .disposed, isFalse, ); expect(player.active, isNot(same(homePlayer))); await player.close(); await home.close(); }); test('should_keep_home_controller_alive_when_player_session_closes', () async { final factory = FakeFactory(); final home = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, owner: PlaybackSessionOwner.home, ); await home.select(0); final homePlayer = home.active! as FakePlayer; final player = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, owner: PlaybackSessionOwner.player, ); await player.select(0); await player.select(1); await player.close(); expect(homePlayer.disposed, isFalse); expect(home.active, same(homePlayer)); expect(home.currentIndex, 0); await home.close(); }); test('should_release_previous_player_immediately_when_selecting_locked', () async { final lockedEpisodes = [ Episode( id: 'free', number: 1, title: 'Free', isFree: true, videoUrl: 'https://example.test/free.mp4', ), Episode( id: 'locked', number: 2, title: 'Locked', isFree: false, locked: true, videoUrl: 'https://example.test/should-not-play.mp4', ), Episode( id: 'next', number: 3, title: 'Next', isFree: true, videoUrl: 'https://example.test/next.mp4', ), ]; final factory = FakeFactory(); final session = PlaybackSession( episodes: lockedEpisodes, factory: factory.create, canPlay: (episode) => !episode.locked, ); await session.select(0, preloadIndex: 1); final previous = factory.created.single; await session.selectLocked(1, preloadIndex: 2, previousIndex: 0); await session.playbackSettled; expect(session.active, isNull); expect(previous.pauseCalls, greaterThan(0)); expect(previous.disposed, isTrue); expect(factory.live, 0); expect(factory.created, hasLength(1)); await session.select(2, previousIndex: 1); expect(session.active, same(factory.created.last)); expect(factory.created.last.playCalls, greaterThan(0)); await session.close(); }); test('should_play_locked_episode_only_after_it_is_unlocked', () async { final lockedEpisodes = [ Episode( id: 'free', number: 1, title: 'Free', isFree: true, videoUrl: 'https://example.test/free.mp4', ), Episode( id: 'locked', number: 2, title: 'Locked', isFree: false, locked: true, videoUrl: 'https://example.test/locked.mp4', ), ]; final unlocked = {}; final factory = FakeFactory(); final session = PlaybackSession( episodes: lockedEpisodes, factory: factory.create, canPlay: (episode) => episode.isFree || unlocked.contains(episode.id), ); await session.select(0); await session.selectLocked(1); expect(session.active, isNull); unlocked.add('locked'); await session.select(1); expect(session.active, same(factory.created.last)); expect(factory.created.last.playCalls, greaterThan(0)); await session.close(); }); test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async { final factory = FakeFactory(holdFirstInitialize: true); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, ); final first = session.select(0); await factory.firstCreated.future; final second = session.select(1); factory.created.first.releaseInitialize(); await Future.wait([first, second]); expect(factory.peakLive, lessThanOrEqualTo(1)); expect(factory.created.first.playCalls, 0); expect(factory.created.first.disposed, isTrue); expect(session.currentIndex, 1); expect(factory.created.last.playCalls, 1); await session.close(); expect(factory.live, 0); }); test( 'should_pause_without_forgetting_intent_when_backgrounding_or_overlay_shows', () async { final factory = FakeFactory(); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, ); await session.select(0); final player = factory.created.single; expect(player.playCalls, 1); session.setAppResumed(false); await Future.delayed(Duration.zero); expect(player.pauseCalls, 1); session.setAppResumed(true); await Future.delayed(Duration.zero); expect(player.playCalls, 2); session.setOverlayOpen(true); await Future.delayed(Duration.zero); expect(player.pauseCalls, 2); session.setUserWantsPlay(false); session.setOverlayOpen(false); await Future.delayed(Duration.zero); expect(player.playCalls, 2); await session.close(); }, ); test('should_finish_resume_preparation_before_video_starts_when_resuming', () async { final factory = FakeFactory(); final prepared = Completer(); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, prepare: (_, _) => prepared.future, ); final selection = session.select(0); await factory.firstCreated.future; await Future.delayed(Duration.zero); expect(factory.created.single.playCalls, 0); prepared.complete(); await selection; expect(factory.created.single.playCalls, 1); await session.close(); }); test('should_resume_playback_when_swiping_after_pause', () async { final factory = FakeFactory(); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, ); await session.select(0); session.setUserWantsPlay(false); await Future.delayed(Duration.zero); expect(session.userWantsPlay, isFalse); await session.select(1); expect(session.userWantsPlay, isTrue); await Future.delayed(Duration.zero); final playing = session.active; expect(playing, isA()); expect((playing! as FakePlayer).playCalls, greaterThan(0)); await session.close(); }); test( 'should_detach_retiring_player_before_disposal_finishes', () async { final disposeStarted = Completer(); final disposeGate = Completer(); final factory = FakeFactory( firstDisposeStarted: disposeStarted, firstDisposeGate: disposeGate, ); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, ); await session.select(0); final retiring = factory.created.single; final switching = session.select(1); await disposeStarted.future; expect(retiring.disposed, isFalse); expect(session.active, isNull); expect(session.preparedPlayerAt(0), isNull); disposeGate.complete(); await switching; expect(retiring.disposed, isTrue); expect(session.active, isA()); await session.close(); }, ); test('should_keep_user_pause_when_reselecting_the_same_episode', () async { final factory = FakeFactory(); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, ); await session.select(0); session.setUserWantsPlay(false); await Future.delayed(Duration.zero); // 页面重新可见等场景会重复选中同一集,这时不能把暂停改成播放。 await session.select(0); expect(session.userWantsPlay, isFalse); expect(session.canAutoAdvance, isFalse); await session.close(); }); test('should_respect_visibility_and_intent_when_advancing_automatically', () async { final factory = FakeFactory(); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (episode) => episode.isFree, ); await session.select(0); expect(session.canAutoAdvance, isTrue); session.setRouteVisible(false); expect(session.canAutoAdvance, isFalse); session.setRouteVisible(true); session.setOverlayOpen(true); expect(session.canAutoAdvance, isFalse); session.setOverlayOpen(false); session.setUserWantsPlay(false); expect(session.canAutoAdvance, isFalse); await session.close(); }); } class FakeFactory { FakeFactory({ this.holdFirstInitialize = false, this.firstDisposeStarted, this.firstDisposeGate, }); final bool holdFirstInitialize; final Completer? firstDisposeStarted; final Completer? firstDisposeGate; final List created = []; final Completer firstCreated = Completer(); int live = 0; int peakLive = 0; PlayerPort create(Episode episode) { live++; if (live > peakLive) peakLive = live; final player = FakePlayer( holdInitialize: holdFirstInitialize && created.isEmpty, disposeStarted: created.isEmpty ? firstDisposeStarted : null, disposeGate: created.isEmpty ? firstDisposeGate : null, onDispose: () => live--, ); created.add(player); if (!firstCreated.isCompleted) firstCreated.complete(); return player; } } class FakePlayer implements PlayerPort { FakePlayer({ required this.holdInitialize, required this.onDispose, this.disposeStarted, this.disposeGate, }); final bool holdInitialize; final void Function() onDispose; final Completer? disposeStarted; final Completer? disposeGate; final Completer _initializeGate = Completer(); bool initialized = false; bool disposed = false; int playCalls = 0; int pauseCalls = 0; void releaseInitialize() => _initializeGate.complete(); @override Future initialize() async { if (holdInitialize) await _initializeGate.future; initialized = true; } @override Future play() async => playCalls++; @override Future pause() async => pauseCalls++; @override Future dispose() async { if (disposed) return; if (disposeStarted != null && !disposeStarted!.isCompleted) { disposeStarted!.complete(); } await disposeGate?.future; disposed = true; onDispose(); } }