import 'dart:convert'; import 'package:flutter/material.dart'; 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/episode_stats.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/playback_api.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.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_http_adapter.dart'; import '../support/fake_watch_history.dart'; Drama _drama(String id) => Drama( id: id, title: id, synopsis: 'synopsis', posterUrl: '/files/poster.png', episodes: [ for (var number = 1; number <= 3; number++) Episode( id: '$id-$number', number: number, title: 'Episode $number', isFree: true, videoUrl: 'https://example.test/$id-$number.mp4', likeCount: 1, commentCount: 1, ), ], ); AndroidPictureInPicture _pip() => AndroidPictureInPicture( channel: const MethodChannel('test/player_episode_stats/pip'), ); PlayerLogic _logic({ required PlaybackRepository playback, DateTime Function()? clock, }) => PlayerLogic( history: FakeWatchHistory(), playback: playback, pictureInPicture: _pip(), waitForFrame: () async {}, clock: clock, ); Future _drain(PlayerLogic logic) async { for (var attempt = 0; attempt < 4; attempt++) { await pumpEventQueue(); await logic.session.playbackSettled; await logic.session.preloadSettled; } await pumpEventQueue(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); test( 'should_refresh_episode_stats_on_up_and_down_swipes_without_reopening_play', () async { var now = DateTime(2026, 10, 26, 12); final adapter = FakeHttpAdapter((options) async { final path = options.uri.path; if (path.endsWith('/play')) { final episodeId = path.split('/')[3]; return FakeHttpResponse( jsonEncode({ 'code': 0, 'message': 'ok', 'data': { 'episodeId': episodeId, 'accessible': true, 'accessType': 'FREE', 'playUrl': '/files/$episodeId.mp4', 'qualities': ['AUTO'], }, }), ); } if (path.endsWith('/stats')) { final episodeId = path.split('/')[3]; return FakeHttpResponse( jsonEncode({ 'code': 0, 'message': 'ok', 'data': { 'episodeId': episodeId, 'dramaId': 'signal', 'likeCount': episodeId == 'signal-1' ? '11' : '22', 'commentCount': episodeId == 'signal-1' ? '3' : '4', 'favoriteCount': '30', 'liked': episodeId == 'signal-1', 'favorited': false, }, }), ); } throw StateError('unexpected request $path'); }); final client = ApiClient( baseUrl: 'http://127.0.0.1:8081', adapter: adapter, ); final playback = HttpPlaybackRepository( PlaybackApi(client), clock: () => now, ); final logs = []; final previousDebugPrint = debugPrint; debugPrint = (message, {wrapWidth}) { if (message == null) return; logs.add(message); if (message.contains('[player] phase=episode-stats-refresh')) { previousDebugPrint(message, wrapWidth: wrapWidth); } }; addTearDown(() { debugPrint = previousDebugPrint; client.close(); }); final factory = _RecordingFactory(); final logic = _logic( playback: playback, clock: () => now, ); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0), isMounted: () => true, ); logic.start( factory: factory.create, coordinator: PlaybackCoordinator(), ); await _drain(logic); await logic.requestEpisodeSelection(1); await _drain(logic); final playRequestsBeforeBack = adapter.requests .where((request) => request.uri.path.endsWith('/play')) .length; final statsRequestsBeforeBack = adapter.requests .where((request) => request.uri.path.endsWith('/stats')) .length; await logic.requestEpisodeSelection(0); await _drain(logic); expect( adapter.requests.where((request) => request.uri.path.endsWith('/play')), hasLength(playRequestsBeforeBack), ); expect( adapter.requests.where( (request) => request.uri.path.endsWith('/stats'), ), hasLength(statsRequestsBeforeBack), ); expect( logs, contains( '[player] phase=episode-stats-refresh episodeId=signal-1 cached=1', ), ); expect(logic.episodeStats('signal-1')?.likeCount, 11); now = now.add(const Duration(seconds: 61)); await logic.requestEpisodeSelection(1); await _drain(logic); await logic.requestEpisodeSelection(0); await _drain(logic); expect( adapter.requests.where( (request) => request.uri.path.endsWith('/stats'), ), hasLength(statsRequestsBeforeBack + 2), ); expect( adapter.requests.where((request) => request.uri.path.endsWith('/play')), hasLength(playRequestsBeforeBack), ); expect( logs.where( (line) => line.contains( 'episode-stats-refresh episodeId=signal-1 cached=0', ), ), hasLength(2), ); logic.dispose(); await _drain(logic); expect(factory.live, 0); }, ); test('should_keep_local_like_count_when_switching_back', () async { final stats = EpisodeStats( episodeId: 'signal-1', dramaId: 'signal', likeCount: 7, commentCount: 2, favoriteCount: 30, liked: false, favorited: false, ); final playback = FakePlaybackRepository( statsByEpisode: {'signal-1': stats}, ); final logic = _logic(playback: playback); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0), isMounted: () => true, ); logic.start( factory: _RecordingFactory().create, coordinator: PlaybackCoordinator(), ); await _drain(logic); expect(logic.episodeStats('signal-1')?.likeCount, 7); logic.applyEpisodeLikeState('signal-1', liked: true, likeCount: 8); await logic.requestEpisodeSelection(1); await _drain(logic); await logic.requestEpisodeSelection(0); await _drain(logic); expect(logic.episodeStats('signal-1')?.liked, isTrue); expect(logic.episodeStats('signal-1')?.likeCount, 8); logic.dispose(); await _drain(logic); }); test('should_keep_playback_when_episode_stats_fail', () async { final playback = FakePlaybackRepository( statsError: StateError('stats unavailable'), ); final logic = _logic(playback: playback); logic.attach( PlayerPage(drama: _drama('signal'), initialIndex: 0), isMounted: () => true, ); logic.start( factory: _RecordingFactory().create, coordinator: PlaybackCoordinator(), ); await _drain(logic); expect(logic.session.active, isNotNull); expect(logic.ticketError, isNull); expect(logic.episodeStats('signal-1'), isNull); logic.dispose(); await _drain(logic); }); } class _RecordingFactory { int live = 0; final List<_RecordingPlayer> created = []; 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; bool disposed = false; @override Future initialize() async {} @override Future play() async {} @override Future pause() async {} @override Future dispose() async { if (disposed) return; disposed = true; onDispose(); } }