Collapse duplicated setup across the suite into six shared helpers under test/test_helpers/ and rewrite the 28 suites that were open-coding it: http_fixtures.dart jsonResponse() for http.Response JSON stubs library_tab_scaffold.dart pumps library tabs under their required ancestors multi_server_fixtures.dart MultiServerProvider wiring for widget tests playback_report_fakes.dart PlaybackReportCall + fake report sinks profile_stack.dart production-shaped profile dependency graph theme.dart testMonoTokens for fast-settling widget tests Net -1245 lines with no change in coverage or assertions.
398 lines
12 KiB
Dart
398 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/focus/focusable_wrapper.dart';
|
|
import 'package:plezy/focus/input_mode_tracker.dart';
|
|
import 'package:plezy/i18n/strings.g.dart';
|
|
import 'package:plezy/media/media_backend.dart';
|
|
import 'package:plezy/media/media_item.dart';
|
|
import 'package:plezy/media/media_kind.dart';
|
|
import 'package:plezy/media/play_queue.dart';
|
|
import 'package:plezy/mpv/mpv.dart';
|
|
import 'package:plezy/media/media_source_info.dart';
|
|
import 'package:plezy/providers/playback_state_provider.dart';
|
|
import 'package:plezy/services/settings_service.dart';
|
|
import 'package:plezy/widgets/video_controls/sheets/queue_sheet.dart';
|
|
import 'package:plezy/widgets/video_controls/widgets/content_strip.dart';
|
|
import 'package:plezy/widgets/video_controls/widgets/media_selector_thumbnail.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../test_helpers/prefs.dart';
|
|
import '../test_helpers/media_items.dart';
|
|
import '../test_helpers/theme.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() async {
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
resetSharedPreferencesForTest();
|
|
SettingsService.resetForTesting();
|
|
await SettingsService.getInstance();
|
|
});
|
|
|
|
testWidgets('MediaSelectorThumbnail applies blur only to real thumbnails', (tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: MediaSelectorThumbnail(
|
|
width: 60,
|
|
height: 34,
|
|
thumbnail: ColoredBox(color: Colors.red),
|
|
isCurrent: true,
|
|
borderColor: Colors.white,
|
|
blurThumbnail: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.byType(ImageFiltered), findsOneWidget);
|
|
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: MediaSelectorThumbnail(
|
|
width: 60,
|
|
height: 34,
|
|
thumbnail: null,
|
|
isCurrent: true,
|
|
borderColor: Colors.white,
|
|
blurThumbnail: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.byType(ImageFiltered), findsNothing);
|
|
});
|
|
|
|
testWidgets('content strip queue blurs spoiler episode thumbnails', (tester) async {
|
|
await SettingsService.instance.write(SettingsService.hideSpoilers, true);
|
|
final playback = _playbackWithQueue();
|
|
addTearDown(playback.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
player: _FakePlayer(),
|
|
chapters: const [],
|
|
chaptersLoaded: true,
|
|
canControl: true,
|
|
showQueueTab: true,
|
|
onQueueItemSelected: (_) {},
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
final thumbnails = tester.widgetList<MediaSelectorThumbnail>(find.byType(MediaSelectorThumbnail)).toList();
|
|
|
|
expect(thumbnails.map((thumbnail) => thumbnail.blurThumbnail), [true, false, false]);
|
|
});
|
|
|
|
testWidgets('content strip falls back from chapters to a focusable queue', (tester) async {
|
|
final playback = _playbackWithQueue();
|
|
addTearDown(playback.dispose);
|
|
final player = _FakePlayer();
|
|
final stripKey = GlobalKey<ContentStripState>();
|
|
final chapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Old Chapter');
|
|
MediaItem? selectedItem;
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
key: stripKey,
|
|
player: player,
|
|
chapters: [chapter],
|
|
chaptersLoaded: true,
|
|
canControl: true,
|
|
showQueueTab: true,
|
|
onQueueItemSelected: (item) => selectedItem = item,
|
|
useFocusNavigation: true,
|
|
),
|
|
),
|
|
);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pump();
|
|
|
|
expect(find.text(t.videoControls.chapters), findsOneWidget);
|
|
expect(find.text('Old Chapter'), findsOneWidget);
|
|
expect(find.text('Spoiler Episode'), findsNothing);
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
key: stripKey,
|
|
player: player,
|
|
chapters: const [],
|
|
chaptersLoaded: true,
|
|
canControl: true,
|
|
showQueueTab: true,
|
|
onQueueItemSelected: (item) => selectedItem = item,
|
|
useFocusNavigation: true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text(t.videoControls.queue), findsOneWidget);
|
|
expect(find.text('Spoiler Episode'), findsOneWidget);
|
|
expect(find.text('Old Chapter'), findsNothing);
|
|
|
|
stripKey.currentState!.requestInitialFocus();
|
|
await tester.pump();
|
|
final firstQueueItem = tester.widget<FocusableWrapper>(
|
|
find.ancestor(of: find.text('Spoiler Episode'), matching: find.byType(FocusableWrapper)),
|
|
);
|
|
expect(firstQueueItem.focusNode!.hasPrimaryFocus, isTrue);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await tester.pump();
|
|
expect(selectedItem?.id, 'spoiler-episode');
|
|
});
|
|
|
|
testWidgets('content strip preserves queue selection until only chapters remain', (tester) async {
|
|
final playback = _playbackWithQueue();
|
|
addTearDown(playback.dispose);
|
|
final player = _FakePlayer();
|
|
final stripKey = GlobalKey<ContentStripState>();
|
|
final initialChapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Initial Chapter');
|
|
final replacementChapter = MediaChapter(id: 2, startTimeOffset: 20000, title: 'Replacement Chapter');
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
key: stripKey,
|
|
player: player,
|
|
chapters: [initialChapter],
|
|
chaptersLoaded: true,
|
|
canControl: true,
|
|
showQueueTab: true,
|
|
onQueueItemSelected: (_) {},
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.tap(find.text(t.videoControls.queue));
|
|
await tester.pump();
|
|
expect(find.text('Spoiler Episode'), findsOneWidget);
|
|
expect(find.text('Initial Chapter'), findsNothing);
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
key: stripKey,
|
|
player: player,
|
|
chapters: [replacementChapter],
|
|
chaptersLoaded: true,
|
|
canControl: true,
|
|
showQueueTab: true,
|
|
onQueueItemSelected: (_) {},
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Spoiler Episode'), findsOneWidget);
|
|
expect(find.text('Replacement Chapter'), findsNothing);
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
key: stripKey,
|
|
player: player,
|
|
chapters: [replacementChapter],
|
|
chaptersLoaded: true,
|
|
canControl: true,
|
|
showQueueTab: false,
|
|
onQueueItemSelected: null,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Replacement Chapter'), findsOneWidget);
|
|
expect(find.text('Spoiler Episode'), findsNothing);
|
|
await tester.tap(find.text('Replacement Chapter'));
|
|
await tester.pump();
|
|
expect(player.seeks, [const Duration(seconds: 20)]);
|
|
});
|
|
|
|
testWidgets('denied chapter remains visible but touch and select do not seek', (tester) async {
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
final player = _FakePlayer();
|
|
final stripKey = GlobalKey<ContentStripState>();
|
|
final chapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Chapter One');
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
key: stripKey,
|
|
player: player,
|
|
chapters: [chapter],
|
|
chaptersLoaded: true,
|
|
canControl: false,
|
|
useFocusNavigation: true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Chapter One'), findsOneWidget);
|
|
await tester.tap(find.text('Chapter One'));
|
|
stripKey.currentState!.requestInitialFocus();
|
|
await tester.pump();
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await tester.pump();
|
|
expect(player.seeks, isEmpty);
|
|
});
|
|
|
|
testWidgets('authorized chapter touch seeks exactly once', (tester) async {
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
final player = _FakePlayer();
|
|
final chapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Chapter One');
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(player: player, chapters: [chapter], chaptersLoaded: true, canControl: true),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.tap(find.text('Chapter One'));
|
|
await tester.pump();
|
|
expect(player.seeks, [const Duration(seconds: 10)]);
|
|
});
|
|
|
|
testWidgets('missing authorized queue callback keeps queue items non-interactive', (tester) async {
|
|
final playback = _playbackWithQueue();
|
|
addTearDown(playback.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: ContentStrip(
|
|
player: _FakePlayer(),
|
|
chapters: const [],
|
|
chaptersLoaded: true,
|
|
canControl: true,
|
|
showQueueTab: true,
|
|
onQueueItemSelected: null,
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(find.text('Spoiler Episode'), findsNothing);
|
|
});
|
|
|
|
testWidgets('queue sheet blurs spoiler episode thumbnails', (tester) async {
|
|
await SettingsService.instance.write(SettingsService.hideSpoilers, true);
|
|
final playback = _playbackWithQueue();
|
|
addTearDown(playback.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
_queueHarness(
|
|
playback: playback,
|
|
child: QueueSheet(onItemSelected: (_) {}),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
final thumbnails = tester.widgetList<MediaSelectorThumbnail>(find.byType(MediaSelectorThumbnail)).toList();
|
|
|
|
expect(thumbnails.map((thumbnail) => thumbnail.blurThumbnail), [true, false, false]);
|
|
});
|
|
}
|
|
|
|
Widget _queueHarness({required PlaybackStateProvider playback, required Widget child}) {
|
|
return ChangeNotifierProvider<PlaybackStateProvider>.value(
|
|
value: playback,
|
|
child: InputModeTracker(
|
|
child: MaterialApp(
|
|
theme: ThemeData(extensions: const [testMonoTokens]),
|
|
home: Scaffold(body: SizedBox(width: 600, height: 400, child: child)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
PlaybackStateProvider _playbackWithQueue() {
|
|
final playback = PlaybackStateProvider();
|
|
playback.setPlaybackFromLocalQueue(
|
|
LocalPlayQueue(
|
|
id: 'test-queue',
|
|
backendId: MediaBackend.plex.id,
|
|
currentIndex: 0,
|
|
items: [
|
|
_episode('spoiler-episode', title: 'Spoiler Episode'),
|
|
_episode('watched-episode', title: 'Watched Episode', viewCount: 1),
|
|
testMediaItem(
|
|
id: 'movie',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
title: 'Movie',
|
|
thumbPath: 'https://example.invalid/movie.jpg',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return playback;
|
|
}
|
|
|
|
MediaItem _episode(String id, {required String title, int? viewCount}) {
|
|
return testMediaItem(
|
|
id: id,
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: title,
|
|
grandparentTitle: 'Show',
|
|
parentIndex: 1,
|
|
index: 1,
|
|
viewCount: viewCount,
|
|
thumbPath: 'https://example.invalid/$id.jpg',
|
|
);
|
|
}
|
|
|
|
class _FakePlayer implements Player {
|
|
final List<Duration> seeks = [];
|
|
|
|
@override
|
|
PlayerState get state => PlayerState(duration: const Duration(minutes: 30));
|
|
|
|
@override
|
|
PlayerStreams get streams => const PlayerStreams(
|
|
playing: Stream<bool>.empty(),
|
|
completed: Stream<bool>.empty(),
|
|
buffering: Stream<bool>.empty(),
|
|
position: Stream<Duration>.empty(),
|
|
duration: Stream<Duration>.empty(),
|
|
seekable: Stream<bool>.empty(),
|
|
buffer: Stream<Duration>.empty(),
|
|
volume: Stream<double>.empty(),
|
|
rate: Stream<double>.empty(),
|
|
tracks: Stream<Tracks>.empty(),
|
|
track: Stream<TrackSelection>.empty(),
|
|
log: Stream<PlayerLog>.empty(),
|
|
error: Stream<PlayerError>.empty(),
|
|
audioDevice: Stream<AudioDevice>.empty(),
|
|
audioDevices: Stream<List<AudioDevice>>.empty(),
|
|
bufferRanges: Stream<List<BufferRange>>.empty(),
|
|
playbackRestart: Stream<void>.empty(),
|
|
backendSwitched: Stream<void>.empty(),
|
|
);
|
|
|
|
@override
|
|
Future<void> seek(Duration position) async {
|
|
seeks.add(position);
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|