Files
ReelShort/test/playback/playback_session_test.dart
T
Vicky 77d5748dbb chore(client): 归档原型阶段未提交改动(binding/logic/view 分层与各页迭代)
为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等),
一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view,
先归档这批既存改动,本批的实际 diff 才说得清边界。

只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。
2026-10-15 17:53:27 +08:00

216 lines
6.5 KiB
Dart

import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/core/data/services/playback_session.dart';
import 'package:nontonki/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('locked episode never creates or initializes a player', () 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('rapid selection drops stale initialize and keeps one player', () 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(
'background and overlay pause without forgetting playback intent',
() 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('resume preparation finishes before video starts', () 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('pausing then swiping to the next episode resumes playback', () 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('re-selecting the same episode keeps the user 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);
// 页面重新可见等场景会重复选中同一集,这时不能把暂停改成播放。
await session.select(0);
expect(session.userWantsPlay, isFalse);
expect(session.canAutoAdvance, isFalse);
await session.close();
});
test('automatic advance respects visibility and playback intent', () 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();
}
}