// 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( 5, (i) => Episode( id: 'ep-$i', number: i + 1, title: 'Episode $i', videoUrl: 'https://example.test/$i.mp4', isFree: i < 4, ), ); test('should_start_warm_playback_when_old_native_disposal_is_still_running', () async { final release = Completer(); final factory = _Factory(disposeGates: {'ep-0': release}); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, ); await session.select(0, preloadIndex: 1); await session.preloadSettled; final selection = session.select(1, preloadIndex: 2); await _tick(); expect(factory.players[1].playing, isTrue); expect(factory.players[0].playing, isFalse); expect(session.loading, isFalse); expect(session.livePlayerCount, 2); expect(factory.players, hasLength(2)); release.complete(); await selection; await session.preloadSettled; expect(factory.peakLive, 2); await session.close(); }); test('should_block_another_route_from_allocating_when_native_release_fails', () async { final factory = _Factory(disposeFailures: {'ep-0'}); final coordinator = PlaybackCoordinator(); PlaybackSession createSession() => PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, coordinator: coordinator, ); final home = createSession(); final series = createSession(); await home.select(0, preloadIndex: 1); await home.preloadSettled; home.setRouteVisible(false); await series.select(2, preloadIndex: 3); expect(series.error, isNotNull); expect(factory.players, hasLength(2)); await series.select(2, preloadIndex: 3); await series.preloadSettled; expect(series.error, isNull); expect(factory.peakLive, 2); await series.close(); await home.close(); expect(factory.live, 0); }); test( 'should_prepare_next_silently_and_promote_it_when_swiped', () async { final factory = _Factory(); final prepared = []; final activated = []; final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (e) => e.isFree, prepare: (e, _) async => prepared.add(e.id), activate: (e, _) async => activated.add(e.id), ); await session.select(0, preloadIndex: 1); await session.preloadSettled; final warm = factory.players[1]; expect(prepared, ['ep-0', 'ep-1']); expect(activated, ['ep-0']); expect(warm.playCalls, 0); expect(session.preloadedIndex, 1); final loadingStates = []; session.addListener(() => loadingStates.add(session.loading)); await session.select(1, preloadIndex: 2); await session.preloadSettled; expect(session.active, same(warm)); expect(warm.initializeCalls, 1); expect(warm.playCalls, 1); expect(loadingStates, isNot(contains(true))); expect(activated, ['ep-0', 'ep-1']); expect(factory.peakLive, 2); expect(factory.peakPlaying, 1); await session.close(); expect(factory.live, 0); }, ); test('should_reuse_in_flight_preload_when_swiped_before_ready', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, ); await session.select(0, preloadIndex: 1); await _tick(); final warm = factory.players[1]; final selection = session.select(1, preloadIndex: 2); await _tick(); expect(session.loading, isTrue); gate.complete(); await selection; await session.preloadSettled; expect(session.active, same(warm)); expect(warm.initializeCalls, 1); expect(factory.peakLive, lessThanOrEqualTo(2)); expect(factory.peakPlaying, 1); await session.close(); }); test('should_discard_stale_warm_work_when_reversing_rapidly_or_skipping_selection', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, ); await session.select(0, preloadIndex: 1); await _tick(); final stale = factory.players[1]; final skip = session.select(3, preloadIndex: 4); final reverse = session.select(0, preloadIndex: 2); gate.complete(); await Future.wait([skip, reverse]); await session.preloadSettled; expect(session.currentIndex, 0); expect(session.preloadedIndex, 2); expect(stale.playCalls, 0); expect(stale.disposed, isTrue); expect(factory.peakLive, lessThanOrEqualTo(2)); expect(factory.peakPlaying, 1); await session.close(); }); test( 'should_never_preload_wrong_episode_when_next_is_locked_or_category_based', () async { final factory = _Factory(); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (e) => e.isFree, ); await session.select(0, preloadIndex: 2); await session.preloadSettled; expect(factory.players.map((p) => p.id), ['ep-0', 'ep-2']); await session.select(0, preloadIndex: 4); await session.preloadSettled; expect(session.preloadedIndex, isNull); expect(factory.players.any((p) => p.id == 'ep-4'), isFalse); await session.close(); }, ); test('should_isolate_preload_failure_and_retry_once_when_selected', () async { final factory = _Factory(failOnce: {'ep-1'}); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, ); await session.select(0, preloadIndex: 1); await session.preloadSettled; expect(session.error, isNull); expect(session.loading, isFalse); expect(factory.players.first.playing, isTrue); await session.select(1); expect(session.error, isNull); expect(factory.players.where((p) => p.id == 'ep-1'), hasLength(2)); await session.close(); }); test('should_start_no_player_when_completion_happens_in_background', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, ); await session.select(0, preloadIndex: 1); await _tick(); final selection = session.select(1, preloadIndex: 2); await _tick(); session.setAppResumed(false); gate.complete(); await selection; await session.preloadSettled; expect(factory.players.every((p) => !p.playing), isTrue); expect(factory.players.any((p) => p.id == 'ep-2'), isFalse); session.setUserWantsPlay(false); session.setAppResumed(true); await session.playbackSettled; expect(factory.players.every((p) => !p.playing), isTrue); await session.close(); }); test('should_dispose_pending_resources_when_closing_during_preload', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, ); await session.select(0, preloadIndex: 1); await _tick(); final closed = session.close(); gate.complete(); await closed; expect(factory.live, 0); expect(factory.players.last.playCalls, 0); }); test( 'should_share_two_player_budget_when_handing_off_between_home_and_series', () async { final factory = _Factory(); final coordinator = PlaybackCoordinator(); PlaybackSession createSession() => PlaybackSession( episodes: episodes, factory: factory.create, canPlay: (_) => true, coordinator: coordinator, ); final home = createSession(); final series = createSession(); await home.select(0, preloadIndex: 1); await home.preloadSettled; home.setRouteVisible(false); await series.select(2, preloadIndex: 3); await series.preloadSettled; expect(home.livePlayerCount, 0); expect(factory.peakLive, 2); expect(factory.peakPlaying, 1); await series.close(); home.setRouteVisible(true); await _tick(); await home.playbackSettled; await home.preloadSettled; expect(home.active, isNotNull); expect(factory.peakLive, 2); await home.close(); expect(factory.live, 0); }, ); } Future _tick() => Future.delayed(Duration.zero); class _Factory { _Factory({ this.gates = const {}, this.disposeGates = const {}, Set? failOnce, Set? disposeFailures, }) : failures = failOnce ?? {}, disposeFailures = disposeFailures ?? {}; final Map> gates; final Map> disposeGates; final Set disposeFailures; final Set failures; final List<_Player> players = []; int live = 0; int peakLive = 0; int peakPlaying = 0; PlayerPort create(Episode episode) { live++; if (live > peakLive) peakLive = live; final player = _Player( episode.id, this, gates[episode.id], failures.remove(episode.id), ); players.add(player); return player; } } class _Player implements PlayerPort { _Player(this.id, this.factory, this.gate, this.fail); final String id; final _Factory factory; final Completer? gate; final bool fail; bool playing = false; bool disposed = false; int initializeCalls = 0; int playCalls = 0; @override Future initialize() async { initializeCalls++; await gate?.future; if (fail) throw StateError('Test initialization failed'); } @override Future play() async { expect(disposed, isFalse); playCalls++; playing = true; final count = factory.players.where((p) => p.playing).length; if (count > factory.peakPlaying) factory.peakPlaying = count; } @override Future pause() async => playing = false; @override Future dispose() async { if (disposed) return; await gate?.future; await factory.disposeGates[id]?.future; if (factory.disposeFailures.remove(id)) { throw StateError('Test disposal failed'); } disposed = true; playing = false; factory.live--; } }