import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.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/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/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/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.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, ), ], ); AndroidPictureInPicture _pip() => AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip')); PlayerLogic _logic( FakeWatchHistory history, { PlaybackRepository? playback, }) => PlayerLogic( history: history, playback: playback ?? FakePlaybackRepository(), pictureInPicture: _pip(), ); /// 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); logic.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; logic.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); }); }); 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'); }); 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); }); }); 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_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); // The last episode has no successor to warm. expect(factory.created, hasLength(1)); 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('lifecycle', () { 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(); } }