470 lines
14 KiB
Dart
470 lines
14 KiB
Dart
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/services/android_picture_in_picture.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<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,
|
|
),
|
|
],
|
|
);
|
|
|
|
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<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('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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
}
|