// Author: fruit.com.cn import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import '../support/fake_watch_history.dart'; import '../support/widget_player.dart'; Drama _drama(String id, {List numbers = const [1, 2, 3]}) => 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: 'https://example.test/clip.mp4', isFree: true, ), ], ); PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) { final logic = PlayerLogic( history: history, playback: FakePlaybackRepository(), pictureInPicture: AndroidPictureInPicture( channel: const MethodChannel('test/progress/pip'), ), waitForFrame: () async {}, ); if (drama != null) { logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, ); logic.start( factory: (episode) => WidgetPlayer(), coordinator: PlaybackCoordinator(), ); } return logic; } /// 排空 session 串行队列,避免测试结束在 dispose 中途。 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('progress throttle', () { test('should_report_once_per_interval_when_progress_ticks_keep_coming', () { final logic = _logic(FakeWatchHistory()); final start = DateTime(2026, 10, 16, 12); logic.lastProgressWrite = start; // 契约建议每 10 秒一次:3 秒、9 秒都不报,第 10 秒报一次 expect(logic.progressInterval, const Duration(seconds: 10)); expect(logic.shouldReportProgress(start.add(const Duration(seconds: 3))), isFalse); expect(logic.shouldReportProgress(start.add(const Duration(seconds: 9))), isFalse); expect(logic.shouldReportProgress(start.add(const Duration(seconds: 10))), isTrue); // 报过之后节流窗口重新开始 expect(logic.shouldReportProgress(start.add(const Duration(seconds: 15))), isFalse); expect(logic.shouldReportProgress(start.add(const Duration(seconds: 20))), isTrue); logic.dispose(); }); test('should_write_immediately_when_saving_on_episode_switch_or_exit', () async { final history = FakeWatchHistory(); final drama = _drama('signal'); final logic = _logic(history, drama: drama); // 切集/退出/切后台走 saveCurrentProgress:不受 10 秒节流限制 await logic.queueProgress( drama, drama.episodes[0], const Duration(seconds: 5), duration: const Duration(minutes: 3), ); await logic.queueProgress( drama, drama.episodes[1], const Duration(seconds: 6), duration: const Duration(minutes: 3), ); expect(history.writes, [ 'signal-1|5000|false', 'signal-2|6000|false', ]); logic.dispose(); }); test('should_report_finished_when_episode_plays_to_the_end', () async { final history = FakeWatchHistory(); final drama = _drama('signal'); final logic = _logic(history, drama: drama); await logic.queueProgress( drama, drama.episodes[0], const Duration(minutes: 3), duration: const Duration(minutes: 3), finished: true, ); expect(history.writes, ['signal-1|180000|true']); logic.dispose(); }); }); group('resume positioning', () { test('should_take_position_from_server_when_record_matches_the_episode', () async { final history = FakeWatchHistory( )..records['signal'] = const WatchProgress( dramaId: 'signal', episodeId: 'signal-2', position: Duration(seconds: 42), duration: Duration(minutes: 3), ); final logic = _logic(history, drama: _drama('signal')); await _drain(logic); await logic.loadResumePoint(); expect(logic.resumePositionFor('signal-2'), const Duration(seconds: 42)); // 记录里不是这一集:不给定位(从头播,避免"看第 1 集却跳到第 2 集的位置") expect(logic.resumePositionFor('signal-1'), isNull); expect(logic.resumePositionFor('signal-3'), isNull); logic.dispose(); }); test('should_have_no_resume_position_when_server_read_fails', () async { final history = FakeWatchHistory()..failReads = true; final logic = _logic(history, drama: _drama('signal')); await _drain(logic); await logic.loadResumePoint(); expect(logic.resumePoint, isNull); expect(logic.resumeLoaded, isTrue); logic.dispose(); }); test('should_not_seek_when_position_is_zero', () async { final history = FakeWatchHistory() ..records['signal'] = const WatchProgress( dramaId: 'signal', episodeId: 'signal-1', position: Duration.zero, duration: Duration(minutes: 3), ); final logic = _logic(history, drama: _drama('signal')); await _drain(logic); await logic.loadResumePoint(); expect(logic.resumePositionFor('signal-1'), isNull); logic.dispose(); await _drain(logic); }); }); }