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 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 _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 = []; 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 = []; 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 initialize() async {} @override Future play() async => plays++; @override Future pause() async => pauses++; @override Future dispose() async { if (disposed) return; disposed = true; onDispose(); } }