Files
ReelShort/test/player/player_episode_stats_test.dart

302 lines
8.4 KiB
Dart

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