提交 Nontonki 短剧原型,准备推送到远程仓库。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
VickyandCursor committed 2026-10-13 16:23:08 +08:00
commit b1f6ce4bf9
148 files changed
+11175

No files matched your search

+344
View File
@@ -0,0 +1,344 @@
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(
5,
(i) => Episode(
id: 'ep-$i',
number: i + 1,
title: 'Episode $i',
assetPath: 'test-$i.mp4',
isFree: i < 4,
),
);
test('warm playback starts before old native disposal finishes', () async {
final release = Completer<void>();
final factory = _Factory(disposeGates: {'ep-0': release});
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
);
await session.select(0, preloadIndex: 1);
await session.preloadSettled;
final selection = session.select(1, preloadIndex: 2);
await _tick();
expect(factory.players[1].playing, isTrue);
expect(factory.players[0].playing, isFalse);
expect(session.loading, isFalse);
expect(session.livePlayerCount, 2);
expect(factory.players, hasLength(2));
release.complete();
await selection;
await session.preloadSettled;
expect(factory.peakLive, 2);
await session.close();
});
test('failed native release blocks another route from allocating', () async {
final factory = _Factory(disposeFailures: {'ep-0'});
final coordinator = PlaybackCoordinator();
PlaybackSession createSession() => PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
coordinator: coordinator,
);
final home = createSession();
final series = createSession();
await home.select(0, preloadIndex: 1);
await home.preloadSettled;
home.setRouteVisible(false);
await series.select(2, preloadIndex: 3);
expect(series.error, isNotNull);
expect(factory.players, hasLength(2));
await series.select(2, preloadIndex: 3);
await series.preloadSettled;
expect(series.error, isNull);
expect(factory.peakLive, 2);
await series.close();
await home.close();
expect(factory.live, 0);
});
test(
'next is prepared silently and promoted without another initialize',
() async {
final factory = _Factory();
final prepared = <String>[];
final activated = <String>[];
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (e) => e.isFree,
prepare: (e, _) async => prepared.add(e.id),
activate: (e, _) async => activated.add(e.id),
);
await session.select(0, preloadIndex: 1);
await session.preloadSettled;
final warm = factory.players[1];
expect(prepared, ['ep-0', 'ep-1']);
expect(activated, ['ep-0']);
expect(warm.playCalls, 0);
expect(session.preloadedIndex, 1);
final loadingStates = <bool>[];
session.addListener(() => loadingStates.add(session.loading));
await session.select(1, preloadIndex: 2);
await session.preloadSettled;
expect(session.active, same(warm));
expect(warm.initializeCalls, 1);
expect(warm.playCalls, 1);
expect(loadingStates, isNot(contains(true)));
expect(activated, ['ep-0', 'ep-1']);
expect(factory.peakLive, 2);
expect(factory.peakPlaying, 1);
await session.close();
expect(factory.live, 0);
},
);
test('in-flight preload is reused when swiped before ready', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
);
await session.select(0, preloadIndex: 1);
await _tick();
final warm = factory.players[1];
final selection = session.select(1, preloadIndex: 2);
await _tick();
expect(session.loading, isTrue);
gate.complete();
await selection;
await session.preloadSettled;
expect(session.active, same(warm));
expect(warm.initializeCalls, 1);
expect(factory.peakLive, lessThanOrEqualTo(2));
expect(factory.peakPlaying, 1);
await session.close();
});
test('rapid reverse and skipped selection discard stale warm work', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
);
await session.select(0, preloadIndex: 1);
await _tick();
final stale = factory.players[1];
final skip = session.select(3, preloadIndex: 4);
final reverse = session.select(0, preloadIndex: 2);
gate.complete();
await Future.wait([skip, reverse]);
await session.preloadSettled;
expect(session.currentIndex, 0);
expect(session.preloadedIndex, 2);
expect(stale.playCalls, 0);
expect(stale.disposed, isTrue);
expect(factory.peakLive, lessThanOrEqualTo(2));
expect(factory.peakPlaying, 1);
await session.close();
});
test(
'category adjacency and locked next never preload the wrong episode',
() async {
final factory = _Factory();
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (e) => e.isFree,
);
await session.select(0, preloadIndex: 2);
await session.preloadSettled;
expect(factory.players.map((p) => p.id), ['ep-0', 'ep-2']);
await session.select(0, preloadIndex: 4);
await session.preloadSettled;
expect(session.preloadedIndex, isNull);
expect(factory.players.any((p) => p.id == 'ep-4'), isFalse);
await session.close();
},
);
test('preload failure is isolated and selecting it retries once', () async {
final factory = _Factory(failOnce: {'ep-1'});
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
);
await session.select(0, preloadIndex: 1);
await session.preloadSettled;
expect(session.error, isNull);
expect(session.loading, isFalse);
expect(factory.players.first.playing, isTrue);
await session.select(1);
expect(session.error, isNull);
expect(factory.players.where((p) => p.id == 'ep-1'), hasLength(2));
await session.close();
});
test('completion in background never starts either player', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
);
await session.select(0, preloadIndex: 1);
await _tick();
final selection = session.select(1, preloadIndex: 2);
await _tick();
session.setAppResumed(false);
gate.complete();
await selection;
await session.preloadSettled;
expect(factory.players.every((p) => !p.playing), isTrue);
expect(factory.players.any((p) => p.id == 'ep-2'), isFalse);
session.setUserWantsPlay(false);
session.setAppResumed(true);
await session.playbackSettled;
expect(factory.players.every((p) => !p.playing), isTrue);
await session.close();
});
test('closing during preloading disposes pending resources', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
);
await session.select(0, preloadIndex: 1);
await _tick();
final closed = session.close();
gate.complete();
await closed;
expect(factory.live, 0);
expect(factory.players.last.playCalls, 0);
});
test(
'home and series share a two-player budget across route handoff',
() async {
final factory = _Factory();
final coordinator = PlaybackCoordinator();
PlaybackSession createSession() => PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (_) => true,
coordinator: coordinator,
);
final home = createSession();
final series = createSession();
await home.select(0, preloadIndex: 1);
await home.preloadSettled;
home.setRouteVisible(false);
await series.select(2, preloadIndex: 3);
await series.preloadSettled;
expect(home.livePlayerCount, 0);
expect(factory.peakLive, 2);
expect(factory.peakPlaying, 1);
await series.close();
home.setRouteVisible(true);
await _tick();
await home.playbackSettled;
await home.preloadSettled;
expect(home.active, isNotNull);
expect(factory.peakLive, 2);
await home.close();
expect(factory.live, 0);
},
);
}
Future<void> _tick() => Future<void>.delayed(Duration.zero);
class _Factory {
_Factory({
this.gates = const {},
this.disposeGates = const {},
Set<String>? failOnce,
Set<String>? disposeFailures,
}) : failures = failOnce ?? {},
disposeFailures = disposeFailures ?? {};
final Map<String, Completer<void>> gates;
final Map<String, Completer<void>> disposeGates;
final Set<String> disposeFailures;
final Set<String> failures;
final List<_Player> players = [];
int live = 0;
int peakLive = 0;
int peakPlaying = 0;
PlayerPort create(Episode episode) {
live++;
if (live > peakLive) peakLive = live;
final player = _Player(
episode.id,
this,
gates[episode.id],
failures.remove(episode.id),
);
players.add(player);
return player;
}
}
class _Player implements PlayerPort {
_Player(this.id, this.factory, this.gate, this.fail);
final String id;
final _Factory factory;
final Completer<void>? gate;
final bool fail;
bool playing = false;
bool disposed = false;
int initializeCalls = 0;
int playCalls = 0;
@override
Future<void> initialize() async {
initializeCalls++;
await gate?.future;
if (fail) throw StateError('Test initialization failed');
}
@override
Future<void> play() async {
expect(disposed, isFalse);
playCalls++;
playing = true;
final count = factory.players.where((p) => p.playing).length;
if (count > factory.peakPlaying) factory.peakPlaying = count;
}
@override
Future<void> pause() async => playing = false;
@override
Future<void> dispose() async {
if (disposed) return;
await gate?.future;
await factory.disposeGates[id]?.future;
if (factory.disposeFailures.remove(id)) {
throw StateError('Test disposal failed');
}
disposed = true;
playing = false;
factory.live--;
}
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
import 'package:nontonki/core/data/services/playback_queue.dart';
void main() {
test(
'home feed advances between dramas, series player between episodes',
() async {
final dramas = await DemoCatalogRepository().fetchDramas();
final home = PlaybackQueue.home(dramas);
expect(home.length, 3);
expect(home.entryAt(0).drama.id, dramas[0].id);
expect(home.entryAt(1).drama.id, dramas[1].id);
expect(home.entryAt(1).episode.number, 1);
expect(home.nextIndex(0), 1);
expect(home.nextIndex(2), isNull);
final series = PlaybackQueue.series(dramas[0]);
expect(series.length, 3);
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
expect(series.entryAt(1).episode.number, 2);
expect(series.nextIndex(0), 1);
expect(series.nextIndex(2), isNull);
},
);
}
+174
View File
@@ -0,0 +1,174 @@
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}',
assetPath: 'assets/media/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('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();
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/pages/player/widgets/seek_progress_bar.dart';
import 'package:video_player/video_player.dart';
void main() {
test('seek target stays inside playable duration', () {
const duration = Duration(seconds: 12);
expect(seekPositionForFraction(0, duration), Duration.zero);
expect(
seekPositionForFraction(0.5, duration),
greaterThan(const Duration(seconds: 5)),
);
expect(seekPositionForFraction(1, duration), lessThan(duration));
});
testWidgets('horizontal slider drag previews and seeks on release', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 3),
isInitialized: true,
),
);
addTearDown(video.dispose);
var started = 0;
Duration? target;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 300,
child: SeekProgressBar(
valueListenable: video,
semanticsLabel: 'Seek video',
onScrubStart: () => started++,
onSeek: (position) async {
target = position;
},
),
),
),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Slider)),
);
await gesture.moveBy(const Offset(120, 0));
await tester.pump();
expect(started, 1);
expect(target, isNull);
await gesture.up();
await tester.pumpAndSettle();
expect(target, isNotNull);
expect(target!, greaterThan(const Duration(seconds: 3)));
expect(target!, lessThan(const Duration(seconds: 12)));
});
testWidgets('compact seek bar shows time only while dragging', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 3),
isInitialized: true,
),
);
addTearDown(video.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SeekProgressBar(
valueListenable: video,
semanticsLabel: 'Seek video',
compactIdle: true,
onSeek: (_) async {},
),
),
),
);
expect(find.text('00:03'), findsNothing);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Slider)),
);
await gesture.moveBy(const Offset(40, 0));
await tester.pump();
expect(find.text('00:12'), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('00:12'), findsNothing);
});
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/watch_history_repository.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
void main() {
test('resume position clamps invalid and completed values', () {
const duration = Duration(seconds: 8);
expect(
clampResumePosition(const Duration(seconds: -1), duration),
Duration.zero,
);
expect(
clampResumePosition(const Duration(seconds: 3), duration),
const Duration(seconds: 3),
);
expect(
clampResumePosition(const Duration(seconds: 9), duration),
Duration.zero,
);
expect(
clampResumePosition(const Duration(milliseconds: 7700), duration),
Duration.zero,
);
});
test('watch history is scoped by profile, drama and episode', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
await history.save(
'profile-a',
'drama-a',
'episode-1',
const Duration(seconds: 3),
);
await history.save(
'profile-a',
'drama-a',
'episode-2',
const Duration(seconds: 5),
);
expect(
await history.positionFor('profile-a', 'drama-a', 'episode-1'),
const Duration(seconds: 3),
);
expect(
await history.positionFor('profile-b', 'drama-a', 'episode-1'),
isNull,
);
expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2');
expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull);
});
}