为什么:上一批归档只覆盖了「已跟踪文件的修改」,原型阶段新增的文件(app_config、theme、 home_feed/play_ticket 模型、catalog_api、测试目录等)还是未入库状态 —— 而已入库的代码会 import 它们, 留在工作区会导致 HEAD 处的代码编译不过。一并归档,让 HEAD 自洽。 纯归档,无行为改动。
480 lines
14 KiB
Dart
480 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nontonki/core/data/models/drama.dart';
|
|
import 'package:nontonki/core/data/models/player_port.dart';
|
|
import 'package:nontonki/core/data/services/android_picture_in_picture.dart';
|
|
import 'package:nontonki/core/data/services/playback_session.dart';
|
|
import 'package:nontonki/pages/player/player_logic.dart';
|
|
import 'package:nontonki/pages/player/player_view.dart';
|
|
|
|
import '../support/fake_watch_history.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) =>
|
|
PlayerLogic(history: history, 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('falls back until a number is picked for that drama', () {
|
|
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('an unknown picked number stops overriding the fallback', () {
|
|
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('walks the visible list and stops at the end', () {
|
|
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('an index outside the visible list has no successor', () {
|
|
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('an empty visible list has no successor', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.visibleIndices = [];
|
|
|
|
expect(logic.nextVisibleIndex(0), isNull);
|
|
});
|
|
});
|
|
|
|
group('surface flags', () {
|
|
test('clearing the screen hides the chrome until it is restored', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
final immersive = <bool>[];
|
|
logic.onImmersiveChanged = immersive.add;
|
|
var notifications = 0;
|
|
logic.addListener(() => notifications++);
|
|
|
|
expect(logic.chromeHidden, isFalse);
|
|
expect(logic.immersive, isFalse);
|
|
|
|
logic.setScreenCleared(true);
|
|
expect(logic.chromeHidden, isTrue);
|
|
expect(notifications, 1);
|
|
expect(immersive, [true]);
|
|
|
|
// Repeating the same value is not a state change.
|
|
logic.setScreenCleared(true);
|
|
expect(notifications, 1);
|
|
expect(immersive, [true]);
|
|
|
|
logic.setScreenCleared(false);
|
|
expect(logic.chromeHidden, isFalse);
|
|
expect(notifications, 2);
|
|
expect(immersive, [true, false]);
|
|
});
|
|
|
|
test('a picture in picture callback outranks the cleared screen flag', () 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('a picture in picture callback is ignored once unmounted', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.isMounted = () => false;
|
|
|
|
logic.onPipChanged(true);
|
|
|
|
expect(logic.pip, isFalse);
|
|
});
|
|
});
|
|
|
|
group('history writes', () {
|
|
test('progress writes are serialized in call order', () 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, [
|
|
'demo-local-v1|signal|signal-1|1000',
|
|
'demo-local-v1|signal|signal-2|2000',
|
|
]);
|
|
});
|
|
|
|
test('a failed write does not drop the writes after it', () 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, ['demo-local-v1|signal|signal-2|2000']);
|
|
});
|
|
|
|
test('saving with no tracked player writes nothing', () 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('a detail route plays a single series', () 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('home mode plays one episode per feed drama', () 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('a locked episode never reaches the player factory', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('locked', isFree: false);
|
|
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.session.isLocked, isTrue);
|
|
expect(factory.created, isEmpty);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('starting from a later index only preloads what follows it', () 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('an inactive route holds no players until it becomes visible', () 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('backgrounding pauses the active player and resuming plays it', () 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('an inactive route stops playing without losing the intent', () 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('is safe to repeat and releases every player', () 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('disables further notifications', () 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();
|
|
}
|
|
}
|