417 lines
12 KiB
Dart
417 lines
12 KiB
Dart
// 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 = <String>{};
|
|
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<void>.delayed(Duration.zero);
|
|
expect(player.pauseCalls, 1);
|
|
session.setAppResumed(true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(player.playCalls, 2);
|
|
session.setOverlayOpen(true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(player.pauseCalls, 2);
|
|
session.setUserWantsPlay(false);
|
|
session.setOverlayOpen(false);
|
|
await Future<void>.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<void>();
|
|
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<void>.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<void>.delayed(Duration.zero);
|
|
expect(session.userWantsPlay, isFalse);
|
|
|
|
await session.select(1);
|
|
|
|
expect(session.userWantsPlay, isTrue);
|
|
await Future<void>.delayed(Duration.zero);
|
|
final playing = session.active;
|
|
expect(playing, isA<FakePlayer>());
|
|
expect((playing! as FakePlayer).playCalls, greaterThan(0));
|
|
await session.close();
|
|
});
|
|
|
|
test(
|
|
'should_detach_retiring_player_before_disposal_finishes',
|
|
() async {
|
|
final disposeStarted = Completer<void>();
|
|
final disposeGate = Completer<void>();
|
|
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<FakePlayer>());
|
|
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<void>.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<void>? firstDisposeStarted;
|
|
final Completer<void>? firstDisposeGate;
|
|
final List<FakePlayer> created = [];
|
|
final Completer<void> firstCreated = Completer<void>();
|
|
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<void>? disposeStarted;
|
|
final Completer<void>? disposeGate;
|
|
final Completer<void> _initializeGate = Completer<void>();
|
|
bool initialized = false;
|
|
bool disposed = false;
|
|
int playCalls = 0;
|
|
int pauseCalls = 0;
|
|
|
|
void releaseInitialize() => _initializeGate.complete();
|
|
|
|
@override
|
|
Future<void> initialize() async {
|
|
if (holdInitialize) await _initializeGate.future;
|
|
initialized = true;
|
|
}
|
|
|
|
@override
|
|
Future<void> play() async => playCalls++;
|
|
|
|
@override
|
|
Future<void> pause() async => pauseCalls++;
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
if (disposed) return;
|
|
if (disposeStarted != null && !disposeStarted!.isCompleted) {
|
|
disposeStarted!.complete();
|
|
}
|
|
await disposeGate?.future;
|
|
disposed = true;
|
|
onDispose();
|
|
}
|
|
}
|