main 的 eaf8fb4 机械性地给全仓加了 // Author 头,develop 侧包名已改 fruitshorts、
并删除了 demo_media/home_feed_category/home_feed_sections 三个文件,故按 develop 口径解决冲突:
保留 fruitshorts 包名与删除结果,补回 Author 头。
218 lines
6.5 KiB
Dart
218 lines
6.5 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_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});
|
|
final bool holdFirstInitialize;
|
|
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,
|
|
onDispose: () => live--,
|
|
);
|
|
created.add(player);
|
|
if (!firstCreated.isCompleted) firstCreated.complete();
|
|
return player;
|
|
}
|
|
}
|
|
|
|
class FakePlayer implements PlayerPort {
|
|
FakePlayer({required this.holdInitialize, required this.onDispose});
|
|
final bool holdInitialize;
|
|
final void Function() onDispose;
|
|
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;
|
|
disposed = true;
|
|
onDispose();
|
|
}
|
|
}
|