Files
ReelShort/test/playback/playback_session_test.dart

269 lines
8.0 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_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();
}
}