// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/interaction.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); }); testWidgets('should_open_fullscreen_overlay_from_discover_card', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(tester); expect(find.byType(PageView), findsOneWidget); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); testWidgets('should_use_black_system_navigation_bar_on_feed_and_player', ( tester, ) async { await _pumpApp(tester); _expectPlayerNavigationBar(tester); await _openSeriesPlayer(tester); _expectPlayerNavigationBar(tester); }); testWidgets('should_pause_and_resume_player_when_page_route_covers_it', ( tester, ) async { final players = <_RecordingPlayer>[]; await _pumpApp( tester, playerFactory: (_) { final player = _RecordingPlayer(); players.add(player); return player; }, ); await _openSeriesPlayer(tester); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); final navigator = tester.state( find.byType(Navigator).first, ); unawaited( navigator.push( MaterialPageRoute( builder: (_) => const Scaffold(key: Key('test-covering-route')), ), ), ); await tester.pumpAndSettle(); expect(find.byKey(const Key('test-covering-route')), findsOneWidget); expect(player.playing, isFalse); navigator.pop(); await tester.pumpAndSettle(); expect(player.playing, isTrue); }); testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( tester, ) async { final players = <_RecordingPlayer>[]; await _pumpApp( tester, playerFactory: (_) { final player = _RecordingPlayer(); players.add(player); return player; }, ); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); await tester.tap(find.text('Sign In')); await tester.pumpAndSettle(); expect(find.byType(AuthPage), findsOneWidget); expect(player.playing, isFalse); await tester.pageBack(); await tester.pumpAndSettle(); expect(player.playing, isTrue); }); testWidgets('should_keep_video_surface_background_black', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect( tester .widget( find.byKey(const Key('player-fallback-background')), ) .color, Colors.black, ); expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); }); testWidgets('should_gate_comments_before_opening_sheet_when_signed_out', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); expect(find.byKey(const Key('player-comments-sheet')), findsNothing); }); testWidgets('should_open_feed_comments_when_signed_in', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); await _pumpApp(tester); await tester.tap(find.byKey(const Key('rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); }); testWidgets('should_refresh_comment_count_after_comment_sheet_closes', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); final comments = FakeCommentRepository(countResult: 97); await _pumpApp(tester, commentRepository: comments); await _openSeriesPlayer(tester); expect( tester .widget(find.byKey(const Key('player-rail-comments-count'))) .data, '96', ); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); expect(find.text('97 comments'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(comments.countCalls, ['city-lights-demo', 'city-lights-demo']); expect( tester .widget(find.byKey(const Key('player-rail-comments-count'))) .data, '97', ); }); testWidgets('should_update_home_feed_favorite_count_after_toggle', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); final catalog = FakeCatalogRepository(dramas: [_countingDrama]); await _pumpApp(tester, catalog: catalog); expect( tester.widget(find.byKey(const Key('rail-save-count'))).data, '2', ); await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); expect( tester.widget(find.byKey(const Key('rail-save-count'))).data, '3', ); }); testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-play-toggle')), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect( tester .widget( find.byKey(const Key('player-play-toggle')), ) .playing, isFalse, ); expect( find.descendant( of: find.byKey(const Key('player-play-toggle')), matching: find.byIcon(Icons.play_arrow_rounded), ), findsOneWidget, ); await tester.tap(find.byKey(const Key('player-play-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( tester .widget( find.byKey(const Key('player-play-toggle')), ) .playing, isFalse, ); await tester.pump(const Duration(seconds: 5)); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( tester .widget( find.byKey(const Key('player-play-toggle')), ) .playing, isFalse, ); expect(find.byKey(const Key('player-video-tap')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byTooltip('Exit clear screen'), findsOneWidget); expect( tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 40), ); await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byType(PlayerCenterToggle), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect(find.byTooltip('Clear screen'), findsOneWidget); }); testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-play-toggle')), findsNothing); final gesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); // 长按弹更多不改播放状态,仍处于播放中。 expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('should_boost_speed_on_side_long_press_and_restore', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); final gesture = await tester.startGesture(const Offset(60, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-episodes-pill')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('2.0x'), ), findsOneWidget, ); // 加速与清屏都不改变播放状态。 expect(find.byKey(const Key('player-play-toggle')), findsNothing); await gesture.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); expect(find.byKey(const Key('player-episodes-pill')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.0x'), ), findsOneWidget, ); expect(find.byKey(const Key('player-play-toggle')), findsNothing); // 右侧 1/3 同样触发加速。 final rightSide = await tester.startGesture(const Offset(330, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); await rightSide.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); }); testWidgets('should_keep_previous_clear_state_after_boost', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); final gesture = await tester.startGesture(const Offset(60, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); expect(find.byKey(const Key('player-episodes-pill')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsNothing); await gesture.up(); await tester.pumpAndSettle(); // 本来已清屏,松手后仍保持清屏,只恢复倍速与提示。 expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); // 清屏态本来显示的选集胶囊与右下按钮,在加速结束后恢复。 expect(find.byKey(const Key('player-episodes-pill')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.0x'), ), findsOneWidget, ); }); testWidgets('should_fit_player_header_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); await _openSeriesPlayer(tester); expect( tester.getSize(find.byKey(const Key('player-back'))), const Size(36, 36), ); expect(tester.takeException(), isNull); }); testWidgets('should_keep_compact_bottom_panel_and_lower_rail', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); final screenHeight = tester.view.physicalSize.height; final bottom = tester.getRect( find.byKey(const Key('player-controls-bottom')), ); final rail = tester.getRect( find.byKey(const Key('player-interaction-rail')), ); expect(find.byKey(const Key('player-background')), findsNothing); final fallback = find.byKey(const Key('player-fallback-background')); expect(fallback, findsOneWidget); expect(tester.widget(fallback).color, Colors.black); expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); expect( tester .widget(find.byKey(const Key('player-scaffold'))) .backgroundColor, Colors.transparent, ); expect( tester .widget(find.byKey(const Key('player-controls-top'))) .decoration, isNull, ); expect( tester .widget(find.byKey(const Key('player-controls-bottom'))) .child, isNotNull, ); expect( tester .widget(find.byKey(const Key('player-episodes-pill'))) .color, AppColors.playerOverlayPill, ); expect( tester .widget(find.byKey(const Key('player-episodes-pill'))) .borderRadius, BorderRadius.circular(7), ); expect( tester .widget( find .descendant( of: find.byKey(const Key('player-episodes-pill')), matching: find.byType(Text), ) .first, ) .style ?.fontSize, 13.5, ); expect( tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 40), ); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); expect(rail.top, greaterThanOrEqualTo(screenHeight * 0.40)); expect(rail.bottom, lessThanOrEqualTo(bottom.top)); expect(find.byKey(const Key('player-author-note')), findsNothing); expect( tester .getSize( find.descendant( of: find.byKey(const Key('player-rail-comments')), matching: find.byType(Image), ), ) .width, 32, ); await tester.tap(find.byKey(const Key('player-synopsis-toggle'))); await tester.pumpAndSettle(); final expandedBottom = tester.getRect( find.byKey(const Key('player-controls-bottom')), ); // K29.9 展开态展示简介全文,高度会增长,但仍需留在画面下半区。 expect(expandedBottom.height, lessThanOrEqualTo(330)); expect(expandedBottom.top, greaterThanOrEqualTo(screenHeight * 0.5)); expect(tester.takeException(), isNull); }); testWidgets('should_keep_series_clear_state_when_swiping_next_episode', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); testWidgets('should_push_episodes_arrow_to_pill_right', (tester) async { // 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。 await _pumpApp(tester, size: const Size(900, 844)); await _openSeriesPlayer(tester); final pill = tester.getRect(find.byKey(const Key('player-episodes-pill'))); final label = tester.getRect( find .descendant( of: find.byKey(const Key('player-episodes-pill')), matching: find.byType(Text), ) .first, ); final arrow = tester.getRect( find.descendant( of: find.byKey(const Key('player-episodes-pill')), matching: find.byIcon(Icons.keyboard_arrow_up_rounded), ), ); expect(pill.right - arrow.right, closeTo(14, 0.5)); expect(arrow.left - label.right, closeTo(8, 0.5)); }); testWidgets('should_show_rail_counts_from_detail_and_follow_server_results', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'test-access', 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); await _pumpApp( tester, interactions: FakeInteractionRepository( nextLikeResult: const LikeToggleResult(liked: true, count: 23000), ), ); await _openSeriesPlayer(tester); String railLabel(Key key) => tester .widget( find.descendant(of: find.byKey(key), matching: find.byType(Text)), ) .data!; expect(railLabel(const Key('player-rail-like')), '1.2万'); expect(railLabel(const Key('player-rail-save')), '3200'); expect(railLabel(const Key('player-rail-comments')), '96'); // 分享没有数量,仍是文字标签。 expect(railLabel(const Key('player-rail-share')), 'Share'); await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); expect(railLabel(const Key('player-rail-like')), '2.3万'); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); expect(railLabel(const Key('player-rail-save')), '3201'); }); testWidgets('should_render_reference_rail_icons_without_background', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect( _railAssetName(tester, const Key('player-rail-save')), 'assets/icon/rail/rail_save.png', ); expect( _railAssetName(tester, const Key('player-rail-comments')), 'assets/icon/rail/rail_comment.png', ); expect( _railAssetName(tester, const Key('player-rail-like')), 'assets/icon/rail/rail_like.png', ); expect( _railAssetName(tester, const Key('player-rail-share')), 'assets/icon/rail/rail_share.png', ); final shareImage = tester.widget( find.descendant( of: find.byKey(const Key('player-rail-share')), matching: find.byType(Image), ), ); expect(shareImage.width, 32); expect(shareImage.height, 32); expect(shareImage.colorBlendMode, BlendMode.srcIn); final saveButton = tester.widget( find.descendant( of: find.byKey(const Key('player-rail-save')), matching: find.byType(IconButton), ), ); expect(saveButton.style?.backgroundColor, isNull); expect( tester.getSize( find.descendant( of: find.byKey(const Key('player-rail-save')), matching: find.byType(IconButton), ), ), const Size(44, 44), ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-rail-like')), matching: find.byType(Text), ), ) .style ?.fontSize, 12, ); }); testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); final toggle = find.byKey(const Key('player-synopsis-toggle')); final synopsis = find.byKey(const Key('player-synopsis')); expect( find.descendant(of: toggle, matching: find.text('More')), findsOneWidget, ); expect(tester.widget(synopsis).maxLines, 1); expect( tester.getRect(toggle).left - tester.getRect(synopsis).right, lessThanOrEqualTo(4), ); await tester.tap(toggle); await tester.pumpAndSettle(); expect( find.descendant(of: toggle, matching: find.text('Less')), findsOneWidget, ); expect(tester.widget(synopsis).maxLines, isNull); await tester.tap(toggle); await tester.pumpAndSettle(); expect( find.descendant(of: toggle, matching: find.text('More')), findsOneWidget, ); expect(tester.widget(synopsis).maxLines, 1); }); testWidgets('should_share_one_muted_style_across_label_and_synopsis', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); TextStyle styleOf(Finder finder) => tester.widget(finder).style ?? const TextStyle(); final label = styleOf(find.byKey(const Key('player-episode-label'))); final synopsis = styleOf(find.byKey(const Key('player-synopsis'))); final separator = styleOf( find.descendant( of: find.byKey(const Key('player-episode-synopsis')), matching: find.text('|'), ), ); for (final style in [label, synopsis, separator]) { expect(style.fontSize, 12); expect(style.fontWeight, FontWeight.w500); expect(style.color?.withValues(alpha: 1), AppColors.playerMuted); } expect(separator.color, isNot(Colors.white)); }); testWidgets('should_fit_long_title_and_synopsis_at_320_width', ( tester, ) async { final longCopy = Drama( id: 'long-copy-demo', title: 'An Extremely Long Drama Title That Keeps Going Beyond Any ' 'Sensible Width On A Narrow Screen', synopsis: 'A very long synopsis that keeps describing the story far beyond ' 'the single line space available in the compact bottom panel of ' 'the fullscreen player.', posterUrl: '/files/long-copy-demo.png', genre: 'Drama', detailLoaded: true, episodes: const [ Episode( id: 'long-copy-demo-1', number: 1, title: 'Episode 1', isFree: true, videoUrl: 'https://example.test/long-demo-1.mp4', ), ], ); await _pumpApp( tester, size: const Size(320, 568), catalog: FakeCatalogRepository(dramas: [longCopy]), ); await _openSeriesPlayer( tester, cardKey: const Key('home-drama-card-long-copy-demo'), ); expect(tester.takeException(), isNull); final title = tester.getRect(find.byKey(const Key('player-drama-title'))); final chevron = tester.getRect( find.descendant( of: find.byKey(const Key('player-open-detail')), matching: find.byIcon(Icons.chevron_right_rounded), ), ); expect(chevron.left - title.right, lessThanOrEqualTo(6)); expect(chevron.right, lessThanOrEqualTo(320 - 14)); final synopsis = tester.widget( find.byKey(const Key('player-synopsis')), ); expect(synopsis.maxLines, 1); expect(synopsis.overflow, TextOverflow.ellipsis); expect( tester.getRect(find.byKey(const Key('player-episodes-pill'))).right, lessThanOrEqualTo(320 - 14 - 40 - 8), ); expect(tester.takeException(), isNull); }); testWidgets('should_open_speed_sheet_and_apply_selected_speed', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); for (final value in ['0.75', '1.0', '1.25', '1.5', '2.0', '3.0']) { expect(find.byKey(Key('player-speed-$value')), findsOneWidget); } await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsNothing); await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, ); }); testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-more')), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, ); }); testWidgets('should_enable_drag_time_preview_on_player_progress', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); final bar = tester.widget( find.byKey(const Key('player-progress')), ); expect(bar.showTimes, isTrue); }); testWidgets('should_render_k28_player_structure_and_episode_grid', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); _expectFullscreenOverlay(tester); await tester.tap(find.byKey(const Key('player-episodes'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); expect(find.byKey(const Key('player-episodes-close')), findsOneWidget); final grid = tester.widget( find.descendant( of: find.byKey(const Key('player-episodes-sheet')), matching: find.byType(GridView), ), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 6); final overlay = tester.widget>( find.byKey(const Key('player-episodes-system-overlay')), ); expect(overlay.value.systemNavigationBarColor, AppColors.card); expect(overlay.value.systemNavigationBarContrastEnforced, isFalse); final safeArea = tester.widget( find .descendant( of: find.byKey(const Key('player-episodes-sheet')), matching: find.byType(SafeArea), ) .first, ); expect(safeArea.bottom, isTrue); expect(tester.takeException(), isNull); }); testWidgets('should_show_locked_episode_in_episode_picker', (tester) async { await _pumpApp( tester, catalog: FakeCatalogRepository(dramas: const [_lockedDrama]), ); await _openSeriesPlayer( tester, cardKey: const Key('home-drama-card-locked-demo'), ); await tester.tap(find.byKey(const Key('player-episodes'))); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('episode-lock-locked-demo-1')), findsOneWidget, ); expect(find.byKey(const Key('player-episode-2')), findsOneWidget); }); testWidgets('should_open_compact_more_actions', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); expect(find.byKey(const Key('player-more-speed')), findsOneWidget); expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-drag-handle')), findsOneWidget); expect(find.byKey(const Key('player-more-close')), findsNothing); for (final removed in const [ Key('player-more-not-interested'), Key('player-more-danmaku'), Key('player-more-danmaku-settings'), Key('player-more-text-size'), Key('player-more-cast'), Key('player-more-timer'), Key('player-more-report'), ]) { expect(find.byKey(removed), findsNothing); } expect( tester.getSize(find.byKey(const Key('player-more-speed-1.0'))).height, 26, ); expect( tester.getSize(find.byKey(const Key('player-more-speed-track'))).height, 34, ); expect( tester.getSize(find.byKey(const Key('player-more-speed-row'))).height, 56, ); expect( tester.getSize(find.byKey(const Key('player-more-quality-row'))).height, 56, ); expect( tester.getSize(find.byKey(const Key('player-more-clear'))).height, 56, ); expect( tester.getSize(find.byKey(const Key('player-more-offline'))).height, 56, ); expect( tester .widget( find.byKey(const Key('player-more-speed')), ) .scrollDirection, Axis.horizontal, ); expect( tester .widget( find.byKey(const Key('player-more-quality')), ) .scrollDirection, Axis.horizontal, ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-speed-1.0')), matching: find.text('1.0x'), ), ) .style ?.fontSize, 13, ); await tester.ensureVisible(find.byKey(const Key('player-more-speed-1.5'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-more-speed-1.5'))); await tester.pumpAndSettle(); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-speed-1.5')), matching: find.text('1.5x'), ), ) .style ?.fontWeight, FontWeight.w600, ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-speed-1.0')), matching: find.text('1.0x'), ), ) .style ?.fontWeight, FontWeight.w400, ); }); testWidgets('should_disable_quality_not_supplied_by_ticket', (tester) async { await _pumpApp( tester, playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'city-lights-demo-1', accessible: true, accessType: PlayTicket.free, playUrl: '/files/city-lights-demo-1.mp4', qualities: ['AUTO'], ), ), ); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-quality-AUTO')), matching: find.byType(InkWell), ), ) .onTap, isNotNull, ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-quality-720P')), matching: find.byType(InkWell), ), ) .onTap, isNull, ); expect(find.text('720P · Unavailable'), findsOneWidget); }); testWidgets('should_show_pip_action_when_platform_supported', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-pip')), findsOneWidget); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); testWidgets('should_hide_pip_action_when_platform_unsupported', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-pip')), findsNothing); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); testWidgets('should_enter_clear_screen_from_more_panel', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-more-clear'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); }); testWidgets('should_enter_clear_screen_from_bottom_button', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-pip')), findsNothing); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byType(PlayerCenterToggle), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); expect(find.byTooltip('Clear screen'), findsOneWidget); }); testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { await _pumpApp( tester, playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'city-lights-demo-1', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, ), ), ); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget); await tester.tap(find.byKey(const Key('player-open-unlock'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-unlock-panel')), findsOneWidget); expect(find.byKey(const Key('player-unlock-coins')), findsOneWidget); expect(find.byKey(const Key('player-unlock-ad')), findsOneWidget); expect(find.byKey(const Key('player-unlock-vip')), findsOneWidget); expect( find.text('Available unlock methods vary by title.'), findsOneWidget, ); }); } String _railAssetName(WidgetTester tester, Key itemKey) { final image = tester.widget( find.descendant(of: find.byKey(itemKey), matching: find.byType(Image)), ); return (image.image as AssetImage).assetName; } void _expectPlayerNavigationBar(WidgetTester tester) { final overlay = tester.widget>( find.byKey(const Key('player-system-overlay')).last, ); expect(overlay.value.systemNavigationBarColor, Colors.black); expect(overlay.value.systemNavigationBarDividerColor, Colors.transparent); expect(overlay.value.systemNavigationBarContrastEnforced, isFalse); expect(overlay.value.systemNavigationBarIconBrightness, Brightness.light); } void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-episode-heading')), findsOneWidget); expect(find.byKey(const Key('player-speed-entry')), findsOneWidget); expect(find.byKey(const Key('player-drama-title')), findsOneWidget); expect(find.byKey(const Key('player-episode-label')), findsOneWidget); expect(find.byKey(const Key('player-synopsis')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget); for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); } expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-pip')), findsNothing); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(find.byKey(const Key('player-title')), findsNothing); expect(find.byKey(const Key('player-subtitle')), findsNothing); expect(find.byKey(const Key('player-genre-chip')), findsNothing); expect(find.byKey(const Key('player-page-dots')), findsNothing); expect(find.byKey(const Key('player-clear-screen')), findsNothing); expect(find.byKey(const Key('player-next')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-rail-like')), findsOneWidget); expect(find.byKey(const Key('player-rail-save')), findsOneWidget); expect(find.byKey(const Key('player-rail-share')), findsOneWidget); } Future _clearFromMore(WidgetTester tester) async { await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-more-clear'))); await tester.pumpAndSettle(); } Future _openSeriesPlayer( WidgetTester tester, { Key cardKey = const Key('home-drama-card-city-lights-demo'), }) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); final card = find.byKey(cardKey); await tester.scrollUntilVisible( card, 220, scrollable: find .descendant( of: find.byKey(const Key('discover-refresh')), matching: find.byType(Scrollable), ) .first, ); await tester.ensureVisible(card); await tester.pumpAndSettle(); await tester.tap(card); await tester.pumpAndSettle(); } Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), FakeCatalogRepository? catalog, FakePlaybackRepository? playback, FakeInteractionRepository? interactions, FakeCommentRepository? commentRepository, PlayerFactory? playerFactory, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final repository = catalog ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: playerFactory ?? createWidgetPlayer, catalogRepository: repository, categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), playbackRepository: playback ?? FakePlaybackRepository(), commentRepository: commentRepository ?? FakeCommentRepository(), interactionRepository: interactions ?? FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), ), ); await tester.pumpAndSettle(); } const _countingDrama = Drama( id: 'counting-demo', title: 'Counting Demo', synopsis: 'Counting interaction totals', posterUrl: '/files/counting-demo.png', genre: 'Romance', detailLoaded: true, favoriteCount: 2, commentCount: 3, likeCount: 4, episodes: [ Episode( id: 'counting-demo-1', number: 1, title: 'Episode 1', isFree: true, videoUrl: 'https://example.test/counting-1.mp4', ), ], ); class _RecordingPlayer implements PlayerPort { bool playing = false; @override Future initialize() async {} @override Future play() async { playing = true; } @override Future pause() async { playing = false; } @override Future dispose() async {} } const _lockedDrama = Drama( id: 'locked-demo', title: 'Locked Demo', synopsis: 'Locked demo synopsis', posterUrl: '/files/locked-demo.png', genre: 'Suspense', detailLoaded: true, episodes: [ Episode( id: 'locked-demo-1', number: 1, title: 'Locked Episode', isFree: false, locked: true, videoUrl: 'https://example.test/locked-demo-1.mp4', ), Episode( id: 'locked-demo-2', number: 2, title: 'Open Episode', isFree: true, videoUrl: 'https://example.test/locked-demo-2.mp4', ), ], );