chore(client): 归档原型阶段新增文件(配置、主题、模型与测试)

为什么:上一批归档只覆盖了「已跟踪文件的修改」,原型阶段新增的文件(app_config、theme、
home_feed/play_ticket 模型、catalog_api、测试目录等)还是未入库状态 —— 而已入库的代码会 import 它们,
留在工作区会导致 HEAD 处的代码编译不过。一并归档,让 HEAD 自洽。

纯归档,无行为改动。
This commit is contained in:
Vicky committed 2026-10-15 17:53:45 +08:00
1 parent e1b98df509
commit feecc349a0
19 files changed
+2210

No files matched your search

+479
View File
@@ -0,0 +1,479 @@
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();
}
}