Files
ReelShort/test/player/player_logic_test.dart

1609 lines
48 KiB
Dart

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<int> 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<void> 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<void> _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);
});
});
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 = <bool>[];
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 = <bool>[];
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<Map<String, String>> restore() async => {
'signal-1': 'file:///local/signal-1.mp4',
};
@override
Future<String?> localPathFor(String episodeId) async =>
episodeId == 'signal-1' ? 'file:///local/signal-1.mp4' : null;
@override
Future<DownloadedMedia> download({
required String episodeId,
required String url,
DownloadProgress? onProgress,
}) async => throw UnimplementedError();
@override
Future<void> delete(String episodeId) async {}
@override
Future<void> clear() async {}
}
class _EmptyDownloadRepository implements DownloadRepository {
@override
Future<DownloadPolicy> fetchPolicy() async => const DownloadPolicy(
maxPerDevice: 20,
licenseDays: 30,
wifiOnly: false,
maxQuality: 'AUTO',
maxStorageBytes: 0,
used: 0,
remaining: 20,
expirePolicyText: '',
);
@override
Future<List<DownloadLicense>> list() async => const [];
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) async =>
throw UnimplementedError();
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {}
@override
Future<DownloadLicense> refreshUrl(String episodeId) async =>
throw UnimplementedError();
@override
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) async => throw UnimplementedError();
@override
Future<void> remove(String episodeId) async {}
@override
Future<int> 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<void> initialize() async {}
@override
Future<void> play() async => plays++;
@override
Future<void> pause() async => pauses++;
@override
Future<void> 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<String> events = [];
PlayerPort create(Episode episode) {
final player = _VideoRecordingPlayer(episode, events);
created.add(player);
return player;
}
}
class _VideoPlayerPlatformStub extends VideoPlayerPlatform {
@override
Future<void> init() async {}
@override
Future<void> dispose(int playerId) async {}
@override
Future<void> setVolume(int playerId, double volume) async {}
@override
Future<void> setPlaybackSpeed(int playerId, double speed) async {}
}
class _VideoRecordingPlayer extends VideoPlayerPort {
_VideoRecordingPlayer(super.episode, this.events);
bool disposed = false;
final List<String> events;
@override
Future<void> 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<void> play() async => events.add('play');
@override
Future<void> pause() async => events.add('pause');
@override
Future<void> 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<void> dispose() async {
if (disposed) return;
disposed = true;
await controller.dispose();
}
}
class _UnlockCommerceRepository implements CommerceRepository {
@override
Future<WalletSnapshot> fetchWallet() async => const WalletSnapshot(
wallet: CoinWallet(
coinBalance: 100,
totalRechargedCoin: 0,
totalConsumedCoin: 0,
totalRefundedCoin: 0,
),
);
@override
Future<UnlockResult> unlock(String episodeId) async => const UnlockResult(
unlocked: true,
accessType: PlayTicket.coinRequired,
coinCost: 30,
coinBalance: 70,
);
}