316 lines
10 KiB
Dart
316 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:reelbox/data/models.dart';
|
|
import 'package:reelbox/media/media_platform.dart';
|
|
import 'package:reelbox/media/player_page.dart';
|
|
import 'package:reelbox/state/app_state.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
const title = TitleRecord(id: 78, title: 'Film');
|
|
const source = MediaSource(
|
|
url: 'https://media.test/film.mkv?token=unchanged',
|
|
type: 'mkv',
|
|
);
|
|
Map<String, dynamic> exitAt(
|
|
int position, {
|
|
bool opened = true,
|
|
bool completed = false,
|
|
}) => {
|
|
'positionMs': position,
|
|
'durationMs': 3600000,
|
|
'opened': opened,
|
|
'completed': completed,
|
|
};
|
|
Future<AppState> mount(
|
|
WidgetTester tester, {
|
|
EpisodeRecord? episode,
|
|
List<EpisodeRecord> playlist = const [],
|
|
List<MediaSource> alternatives = const [],
|
|
String? localFile,
|
|
}) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final state = AppState(storage: await SharedPreferences.getInstance());
|
|
await state.initialize(discover: false);
|
|
await state.updateSettings(state.prefs.copyWith(autoplay: true));
|
|
await state.recordWatch(
|
|
title,
|
|
sourceUrl: source.url,
|
|
positionSeconds: 600,
|
|
durationSeconds: 3600,
|
|
episodeId: episode?.id,
|
|
episodeTitle: episode?.title ?? '',
|
|
);
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: state,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
title: title,
|
|
source: source,
|
|
episode: episode,
|
|
playlist: playlist,
|
|
alternatives: alternatives,
|
|
localFile: localFile,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
return state;
|
|
}
|
|
|
|
void main() {
|
|
testWidgets(
|
|
'early native EOF never advances or records a completed episode',
|
|
(tester) async {
|
|
const first = EpisodeRecord(
|
|
id: 101,
|
|
title: 'Episode 1',
|
|
sources: [source],
|
|
);
|
|
const second = EpisodeRecord(
|
|
id: 102,
|
|
title: 'Episode 2',
|
|
sources: [MediaSource(url: 'https://media.test/episode2.mkv')],
|
|
);
|
|
final calls = <MethodCall>[];
|
|
final firstExit = Completer<Map<String, dynamic>>();
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async {
|
|
if (call.method != 'openNativePlayer') return null;
|
|
calls.add(call);
|
|
return calls.length == 1
|
|
? firstExit.future
|
|
: exitAt(0, opened: false);
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
null,
|
|
),
|
|
);
|
|
final state = await mount(
|
|
tester,
|
|
episode: first,
|
|
playlist: [first, second],
|
|
);
|
|
addTearDown(state.dispose);
|
|
firstExit.complete(exitAt(180000, completed: true));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
calls,
|
|
hasLength(1),
|
|
reason: 'Three minutes is not the end of this episode',
|
|
);
|
|
expect(state.history, hasLength(1));
|
|
expect(state.history.single.episodeId, first.id);
|
|
expect(state.history.single.positionSeconds, 180);
|
|
expect(state.history.single.durationSeconds, 3600);
|
|
expect(tester.takeException(), isNull);
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
|
);
|
|
|
|
testWidgets(
|
|
'auto-next recovery keeps alternatives and history on the current episode',
|
|
(tester) async {
|
|
const oldAlternative = MediaSource(
|
|
url: 'https://media.test/episode1-720.mkv',
|
|
quality: '720p',
|
|
);
|
|
const first = EpisodeRecord(
|
|
id: 101,
|
|
title: 'Episode 1',
|
|
sources: [source, oldAlternative],
|
|
);
|
|
const secondSource = MediaSource(url: 'https://media.test/episode2.mkv');
|
|
const secondAlternative = MediaSource(
|
|
url: 'https://media.test/episode2-720.mkv?token=unchanged',
|
|
quality: '720p',
|
|
);
|
|
const trailer = MediaSource(
|
|
url: 'https://media.test/episode2-trailer.mp4',
|
|
quality: 'Trailer',
|
|
);
|
|
const denied = MediaSource(
|
|
url: 'https://t.me/episode2',
|
|
quality: 'Denied',
|
|
);
|
|
const second = EpisodeRecord(
|
|
id: 102,
|
|
title: 'Episode 2',
|
|
sources: [secondSource, secondAlternative, trailer, denied],
|
|
);
|
|
final calls = <MethodCall>[];
|
|
final firstExit = Completer<Map<String, dynamic>>();
|
|
final recoveryExit = Completer<Map<String, dynamic>>();
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async {
|
|
if (call.method != 'openNativePlayer') return null;
|
|
calls.add(call);
|
|
if (calls.length == 1) return firstExit.future;
|
|
if (calls.length == 2) return exitAt(0, opened: false);
|
|
return recoveryExit.future;
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
null,
|
|
),
|
|
);
|
|
final state = await mount(
|
|
tester,
|
|
episode: first,
|
|
playlist: [first, second],
|
|
alternatives: first.sources,
|
|
);
|
|
addTearDown(state.dispose);
|
|
firstExit.complete(exitAt(3600000, completed: true));
|
|
await tester.pumpAndSettle();
|
|
expect(calls.length, 2);
|
|
expect(calls.last.arguments['url'], secondSource.url);
|
|
expect(find.text('Episode 2'), findsOneWidget);
|
|
expect(find.text('Retry'), findsOneWidget);
|
|
await tester.tap(find.widgetWithText(ListTile, '720p'));
|
|
await tester.pump();
|
|
expect(calls.length, 3);
|
|
// Both episodes offer 720p: the row must dispatch episode 2, not 1.
|
|
final recoveryUrl = calls.last.arguments['url'];
|
|
recoveryExit.complete(exitAt(10000));
|
|
await tester.pumpAndSettle();
|
|
final recovered = state.history.singleWhere((e) => e.episodeId == 102);
|
|
expect(recovered.episodeTitle, second.title);
|
|
expect(recovered.positionSeconds, 10);
|
|
expect(recoveryUrl, secondAlternative.url);
|
|
expect(recovered.sourceUrl, secondAlternative.url);
|
|
expect(state.history.map((e) => e.episodeId).toSet(), {101, 102});
|
|
expect(find.text('Trailer'), findsNothing);
|
|
expect(find.text('Denied'), findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
|
);
|
|
|
|
testWidgets(
|
|
'Android default player dispatches VLC with resume and preserves source identity',
|
|
(tester) async {
|
|
final calls = <MethodCall>[];
|
|
final result = Completer<Map<String, dynamic>>();
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async {
|
|
calls.add(call);
|
|
if (call.method == 'openNativePlayer') return result.future;
|
|
return null;
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
null,
|
|
),
|
|
);
|
|
final state = await mount(tester);
|
|
addTearDown(state.dispose);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
final call = calls.singleWhere(
|
|
(call) => call.method == 'openNativePlayer',
|
|
);
|
|
expect(call.arguments['url'], source.url);
|
|
expect(call.arguments['positionMs'], 595000);
|
|
expect(call.arguments['autoplay'], true);
|
|
expect(state.history.single.positionSeconds, 600);
|
|
result.complete(exitAt(700000));
|
|
await tester.pumpAndSettle();
|
|
expect(state.history.single.positionSeconds, 700);
|
|
expect(state.history.single.sourceUrl, source.url);
|
|
expect(tester.takeException(), isNull);
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
|
);
|
|
|
|
testWidgets('failed native open never overwrites a saved checkpoint', (
|
|
tester,
|
|
) async {
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async {
|
|
if (call.method == 'openNativePlayer') return exitAt(0, opened: false);
|
|
return null;
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
null,
|
|
),
|
|
);
|
|
final state = await mount(tester);
|
|
addTearDown(state.dispose);
|
|
await tester.pumpAndSettle();
|
|
expect(state.history.single.positionSeconds, 600);
|
|
expect(find.text('External player'), findsOneWidget);
|
|
expect(find.text('Retry'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
|
|
|
|
testWidgets(
|
|
'natural native episode completion opens next episode without losing history',
|
|
(tester) async {
|
|
const first = EpisodeRecord(
|
|
id: 101,
|
|
title: 'Episode 1',
|
|
sources: [source],
|
|
);
|
|
const second = EpisodeRecord(
|
|
id: 102,
|
|
title: 'Episode 2',
|
|
sources: [MediaSource(url: 'https://media.test/episode2.mkv')],
|
|
);
|
|
final calls = <MethodCall>[];
|
|
final firstExit = Completer<Map<String, dynamic>>();
|
|
final secondExit = Completer<Map<String, dynamic>>();
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async {
|
|
if (call.method != 'openNativePlayer') return null;
|
|
calls.add(call);
|
|
return calls.length == 1 ? firstExit.future : secondExit.future;
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
null,
|
|
),
|
|
);
|
|
final state = await mount(
|
|
tester,
|
|
episode: first,
|
|
playlist: [first, second],
|
|
);
|
|
addTearDown(state.dispose);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
expect(calls.length, 1);
|
|
firstExit.complete(exitAt(3600000, completed: true));
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
expect(calls.length, 2);
|
|
expect(calls.last.arguments['url'], second.sources.single.url);
|
|
expect(calls.last.arguments['positionMs'], 0);
|
|
expect(state.history.single.episodeId, 101);
|
|
secondExit.complete(exitAt(10000));
|
|
await tester.pumpAndSettle();
|
|
expect(state.history.map((e) => e.episodeId).toSet(), {101, 102});
|
|
expect(tester.takeException(), isNull);
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
|
);
|
|
}
|