302 lines
8.4 KiB
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();
|
|
}
|
|
}
|