// Author: fruit.com.cn import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/comment.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart'; import 'package:provider/provider.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 '../support/fake_http_adapter.dart'; import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; const _drama = Drama( id: '5', title: 'The Signal', synopsis: 'synopsis', posterUrl: '/files/5.jpg', ); void main() { testWidgets('should_ignore_stale_keyboard_inset_when_composer_opens', ( tester, ) async { const keyboardChannel = MethodChannel('com.stormg.lumadrama/keyboard'); debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( keyboardChannel, (call) async => call.method == 'get' ? 300 : null, ); addTearDown(() { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( keyboardChannel, null, ); debugDefaultTargetPlatformOverride = null; }); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); final composer = find.byKey(const Key('comment-composer-sheet')); final panel = find.byKey(const Key('comment-composer-panel')); final input = find.byKey(const Key('comment-composer-input')); expect( find.byKey(const Key('comment-composer-focus-scope')), findsOneWidget, ); expect(tester.getSize(composer).height, inInclusiveRange(100, 150)); expect( tester.getSize(panel).height, closeTo(tester.getSize(composer).height, 1), ); expect(tester.getBottomLeft(panel).dy, closeTo(844, 0.1)); expect(tester.widget(input).focusNode?.hasFocus, isTrue); expect(tester.testTextInput.isVisible, isTrue); debugDefaultTargetPlatformOverride = null; }); setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'test-access', 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); }); testWidgets('should_follow_live_view_insets_on_ios', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); tester.view.viewInsets = const FakeViewPadding(bottom: 300); await tester.pump(); await tester.pump(const Duration(milliseconds: 160)); final animatedPadding = tester.widget( find.byKey(const Key('comment-composer-keyboard-padding')), ); expect((animatedPadding.padding as EdgeInsets).bottom, 300); expect( tester.getBottomLeft(find.byKey(const Key('comment-composer-post'))).dy, lessThanOrEqualTo(844 - 300), ); debugDefaultTargetPlatformOverride = null; }); testWidgets('should_use_zero_live_inset_when_ios_keyboard_is_hidden', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); final animatedPadding = tester.widget( find.byKey(const Key('comment-composer-keyboard-padding')), ); expect((animatedPadding.padding as EdgeInsets).bottom, 0); debugDefaultTargetPlatformOverride = null; }); testWidgets('should_gate_guest_comment_input_without_focus', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); final placeholder = find.byKey(const Key('comment-input-placeholder')); expect(placeholder, findsOneWidget); expect(find.text('Sign in to comment'), findsOneWidget); await tester.tap(placeholder); await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); await tester.tap(find.text('Not now')); await tester.pumpAndSettle(); expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(placeholder, findsOneWidget); }); testWidgets('should_focus_comment_input_after_successful_login', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final repository = FakeCommentRepository(pages: [const CommentPage()]); final auth = await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.text('Sign In')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); (auth as _TestAuthController).setSignedIn(true); await tester.pump(); Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop(); await tester.pumpAndSettle(); expect( tester .widget(find.byKey(const Key('comment-composer-input'))) .focusNode ?.hasFocus, isTrue, ); }); testWidgets('should_open_composer_sheet_when_fake_send_is_tapped', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); final composer = find.byKey(const Key('comment-composer-sheet')); expect(composer, findsOneWidget); final compactHeight = tester.getSize(composer).height; expect(compactHeight, inInclusiveRange(100, 150)); final input = find.byKey(const Key('comment-composer-input')); expect(input, findsOneWidget); expect(tester.widget(input).focusNode?.hasFocus, isTrue); expect( tester.widget(input).textAlignVertical, TextAlignVertical.center, ); await tester.enterText(input, 'First line\nSecond line'); await tester.pump(); expect(tester.getSize(composer).height, greaterThan(compactHeight)); expect(tester.getSize(composer).height, lessThan(844 / 2)); final multilineField = tester.widget(input); expect(multilineField.textAlignVertical, TextAlignVertical.top); expect(multilineField.style?.height, 1.4); }); testWidgets('should_gate_guest_post_without_creating_comment', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); expect(repository.publishCalls, isEmpty); }); testWidgets('should_show_server_comments_and_append_next_cursor_page', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [ commentFixture(id: '10', content: 'Great episode', replyCount: 1), ], nextCursor: '10', hasMore: true, ), CommentPage( comments: [commentFixture(id: '9', content: 'Second page')], ), ], ); await _pumpSheet(tester, repository: repository); expect(find.text('Great episode'), findsOneWidget); expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget); expect( tester.widget(find.byKey(const Key('comment-count-label'))).data, 'Comments', ); await tester.tap(find.byKey(const Key('comment-list-load-more'))); await tester.pumpAndSettle(); expect(find.text('Second page'), findsOneWidget); expect(find.text('2 comments'), findsOneWidget); expect(repository.pageCalls.last.cursor, '10'); }); testWidgets('should_show_server_total_that_includes_replies', (tester) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [ commentFixture(id: '10', content: 'Root one'), commentFixture(id: '11', content: 'Root two'), ], ), ], countResult: 9, ); await _pumpSheet(tester, repository: repository); expect(find.text('9 comments'), findsOneWidget); expect(repository.countCalls, ['5']); }); testWidgets('should_scope_list_count_and_publish_to_episode', (tester) async { final repository = FakeCommentRepository( pages: [const CommentPage()], countResult: 3, publishResult: commentFixture( id: '11', content: 'Episode comment', mine: true, ), ); await _pumpSheet(tester, repository: repository, episodeId: 'episode-7'); expect(repository.pageCalls.single.episodeId, 'episode-7'); expect(repository.countEpisodeCalls, ['episode-7']); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const Key('comment-composer-input')), 'Episode comment', ); await tester.pump(); await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); expect(repository.publishCalls.single.episodeId, 'episode-7'); expect(repository.countEpisodeCalls, ['episode-7', 'episode-7']); }); testWidgets('should_fall_back_to_exact_count_when_server_total_fails', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [ commentFixture(id: '10', content: 'Root one'), commentFixture(id: '11', content: 'Root two'), ], ), ], countError: StateError('offline'), ); await _pumpSheet(tester, repository: repository); expect(find.text('2 comments'), findsOneWidget); }); testWidgets('should_show_compact_empty_state_when_no_comments_exist', ( tester, ) async { final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); expect(find.byKey(const Key('comment-list-empty')), findsOneWidget); expect(find.byType(EmptyState), findsOneWidget); expect(tester.widget(find.byType(EmptyState)).compact, isTrue); expect(find.text('No comments yet.'), findsOneWidget); }); testWidgets( 'should_keep_input_and_show_backend_message_when_comment_is_blocked', (tester) async { final repository = FakeCommentRepository( pages: [const CommentPage()], publishError: const ApiException(400, '评论包含敏感词'), ); await _pumpSheet( tester, repository: repository, locale: const Locale('zh'), ); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const Key('comment-composer-input')), 'blocked text', ); await tester.pump(); await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); expect(find.text('评论包含敏感词'), findsOneWidget); final field = tester.widget( find.byKey(const Key('comment-composer-input')), ); expect(field.controller?.text, 'blocked text'); expect(find.text('发表成功'), findsNothing); // 发送失败时保留输入与焦点,用户可以直接改完再发。 expect(field.focusNode?.hasFocus, isTrue); }, ); testWidgets('should_close_composer_after_successful_post', (tester) async { addTearDown(tester.view.resetViewInsets); final repository = FakeCommentRepository( pages: [const CommentPage()], publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true), countResult: 1, ); await _pumpSheet( tester, repository: repository, locale: const Locale('zh'), ); final input = find.byKey(const Key('comment-composer-input')); tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); expect(tester.widget(input).focusNode?.hasFocus, isTrue); await tester.enterText(input, 'Nice demo'); await tester.pump(); expect( tester .widget(find.byKey(const Key('comment-composer-post'))) .onPressed, isNotNull, ); await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); final successOverlay = find.byKey( const Key('comment-post-success-snackbar'), ); expect(successOverlay, findsOneWidget); expect(find.text('发表成功'), findsOneWidget); expect(find.byType(SnackBar), findsNothing); final capsule = tester.widget(successOverlay); final decoration = capsule.decoration! as BoxDecoration; expect(decoration.color, Colors.black.withValues(alpha: 0.82)); expect(decoration.borderRadius, BorderRadius.circular(8)); expect( capsule.padding, const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ); final successText = tester.widget(find.text('发表成功')); expect(successText.style?.color, Colors.white); expect(successText.style?.fontSize, 14); expect(successText.style?.decoration, TextDecoration.none); final screen = tester.view.physicalSize / tester.view.devicePixelRatio; final toast = tester.getRect(successOverlay); expect(toast.center.dy, lessThan(screen.height * 0.4)); expect(toast.center.dy, greaterThan(screen.height * 0.2)); final placeholderRect = tester.getRect( find.byKey(const Key('comment-input-placeholder')), ); expect( tester.getRect(successOverlay).bottom, lessThan(placeholderRect.top), ); await tester.pump(const Duration(milliseconds: 1200)); expect(successOverlay, findsNothing); expect(find.byKey(const Key('comment-list')), findsOneWidget); expect(find.byKey(const Key('comment-count-label')), findsOneWidget); expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); expect(repository.countCalls, ['5', '5']); }); testWidgets('should_keep_comment_panel_fixed_and_open_composer_sheet', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'A comment')], ), ], ); await _pumpSheet(tester, repository: repository); final commentsSheet = find.byKey(const Key('player-comments-sheet')); final list = find.byKey(const Key('comment-list')); final placeholder = find.byKey(const Key('comment-input-placeholder')); final sheetRect = tester.getRect(commentsSheet); final sheetHeight = tester.getSize(commentsSheet).height; await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); final composer = find.byKey(const Key('comment-composer-sheet')); final composerPanel = find.byKey(const Key('comment-composer-panel')); final composerInput = find.byKey(const Key('comment-composer-input')); expect(composer, findsOneWidget); expect(composerPanel, findsOneWidget); final overlayRoot = find.byKey(const Key('comment-composer-overlay')); expect(overlayRoot, findsOneWidget); final overlayStack = tester.widget(overlayRoot); expect(overlayStack.children, hasLength(2)); final dismissLayer = overlayStack.children.first as Positioned; expect(dismissLayer.left, 0); expect(dismissLayer.top, 0); expect(dismissLayer.right, 0); expect(dismissLayer.bottom, 0); final dismissGesture = dismissLayer.child as GestureDetector; expect(dismissGesture.child, isNull); expect(dismissGesture.behavior, HitTestBehavior.opaque); final compactHeight = tester.getSize(composer).height; expect(compactHeight, inInclusiveRange(100, 150)); expect(tester.widget(composerInput).focusNode?.hasFocus, isTrue); final underlyingSheet = find.byKey( const Key('player-comments-sheet'), skipOffstage: false, ); expect(tester.getRect(underlyingSheet), sheetRect); expect(tester.getSize(underlyingSheet).height, closeTo(sheetHeight, 0.1)); final field = tester.widget(composerInput); expect(field.minLines, 1); expect(field.maxLines, 4); expect(field.style?.fontSize, 15); expect(find.byIcon(Icons.image_outlined), findsNothing); expect(find.byIcon(Icons.emoji_emotions_outlined), findsNothing); expect(find.byIcon(Icons.alternate_email_rounded), findsNothing); tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.pump(); await tester.pump(const Duration(milliseconds: 90)); await tester.pump(const Duration(milliseconds: 200)); final keyboardUpInputTop = tester.getTopLeft(composer).dy; final keyboardUpBottom = tester.getBottomLeft(composer).dy; final keyboardUpPanelRect = tester.getRect(composerPanel); expect(tester.getSize(composer).height, closeTo(compactHeight, 0.1)); expect(keyboardUpBottom, closeTo(844 - 180, 0.1)); expect(keyboardUpPanelRect.bottom, closeTo(844, 0.1)); expect( tester.getBottomLeft(composerInput).dy, lessThanOrEqualTo(844 - 180), ); expect(tester.getRect(underlyingSheet), sheetRect); tester.view.viewInsets = const FakeViewPadding(); await tester.pumpAndSettle(); expect(composer, findsOneWidget); expect(tester.getTopLeft(composer).dy, closeTo(keyboardUpInputTop, 0.1)); expect(tester.getBottomLeft(composer).dy, closeTo(keyboardUpBottom, 0.1)); expect(tester.getRect(composerPanel), keyboardUpPanelRect); expect(tester.widget(composerPanel).color, AppColors.card); expect(tester.getBottomLeft(composerPanel).dy, closeTo(844, 0.1)); expect(commentsSheet, findsOneWidget); expect(list, findsOneWidget); expect(placeholder, findsOneWidget); expect(tester.getRect(commentsSheet), sheetRect); expect(tester.getSize(commentsSheet).height, closeTo(sheetHeight, 0.1)); await tester.tapAt(const Offset(20, 20)); await tester.pumpAndSettle(); expect(composer, findsNothing); await tester.tap(placeholder); await tester.pumpAndSettle(); expect(composer, findsOneWidget); expect(tester.getBottomLeft(composer).dy, closeTo(844, 0.1)); debugDefaultTargetPlatformOverride = null; }); testWidgets( 'should_keep_composer_input_position_when_safe_area_padding_changes', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; tester.view.padding = const FakeViewPadding(); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); addTearDown(tester.view.resetPadding); final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'A comment')], ), ], ); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); final input = find.byKey(const Key('comment-composer-input')); tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.pump(); await tester.pump(const Duration(milliseconds: 90)); await tester.pump(const Duration(milliseconds: 200)); final keyboardUpInputRect = tester.getRect(input); tester.view.viewInsets = const FakeViewPadding(); tester.view.padding = const FakeViewPadding(bottom: 48); await tester.pumpAndSettle(); final keyboardClosedInputRect = tester.getRect(input); expect( keyboardClosedInputRect.top, closeTo(keyboardUpInputRect.top, 0.1), ); expect( keyboardClosedInputRect.bottom, closeTo(keyboardUpInputRect.bottom, 0.1), ); expect( keyboardClosedInputRect.height, closeTo(keyboardUpInputRect.height, 0.1), ); debugDefaultTargetPlatformOverride = null; }, ); testWidgets( 'should_keep_composer_position_when_keyboard_insets_change_after_settling', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 2200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'A comment')], ), ], ); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); final composer = find.byKey(const Key('comment-composer-sheet')); expect(composer, findsOneWidget); tester.view.viewInsets = const FakeViewPadding(bottom: 1100); await tester.pump(); await tester.pump(const Duration(milliseconds: 90)); await tester.pump(const Duration(milliseconds: 200)); final settledTop = tester.getTopLeft(composer).dy; final settledBottom = tester.getBottomLeft(composer).dy; final settledHeight = tester.getSize(composer).height; expect(settledBottom, closeTo(2200 - 1100, 0.1)); tester.view.viewInsets = const FakeViewPadding(bottom: 1150); await tester.pump(); await tester.pump(const Duration(milliseconds: 90)); expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1)); expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1)); expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1)); tester.view.viewInsets = const FakeViewPadding(bottom: 600); await tester.pump(); await tester.pump(const Duration(milliseconds: 90)); expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1)); expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1)); expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1)); tester.view.viewInsets = const FakeViewPadding(); await tester.pumpAndSettle(); expect(composer, findsOneWidget); expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1)); expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1)); expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1)); debugDefaultTargetPlatformOverride = null; }, ); testWidgets('should_keep_composer_open_after_input_loses_focus', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'A comment')], ), ], ); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); final composer = find.byKey(const Key('comment-composer-sheet')); final input = find.byKey(const Key('comment-composer-input')); expect(composer, findsOneWidget); tester.widget(input).focusNode?.unfocus(); await tester.pumpAndSettle(); expect(composer, findsOneWidget); await tester.tap(input); await tester.pump(); expect(composer, findsOneWidget); expect(tester.widget(input).focusNode?.hasFocus, isTrue); }); testWidgets('should_close_composer_before_comment_panel_from_outside', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'A comment')], ), ], ); await _pumpSheet(tester, repository: repository); final sheet = find.byKey(const Key('player-comments-sheet')); expect( find.byKey(const Key('player-comments-dismiss-layer')), findsOneWidget, ); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); expect(sheet, findsOneWidget); final composer = find.byKey(const Key('comment-composer-sheet')); expect(composer, findsOneWidget); expect( find.byKey(const Key('comment-composer-dismiss-layer')), findsOneWidget, ); final sheetRect = tester.getRect(sheet); final composerRect = tester.getRect(composer); await tester.tap(find.byKey(const Key('comment-composer-input'))); await tester.pump(); expect(composer, findsOneWidget); await tester.tapAt( Offset(sheetRect.center.dx, (sheetRect.top + composerRect.top) / 2), ); await tester.pumpAndSettle(); expect(sheet, findsOneWidget); expect(composer, findsNothing); await tester.tapAt(const Offset(20, 20)); await tester.pumpAndSettle(); expect(sheet, findsNothing); }); testWidgets('should_hide_keyboard_on_back_without_closing_composer_overlay', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'A comment')], ), ], ); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); final sheet = find.byKey(const Key('player-comments-sheet')); final composer = find.byKey(const Key('comment-composer-sheet')); expect(sheet, findsOneWidget); expect(composer, findsOneWidget); final input = find.byKey(const Key('comment-composer-input')); await tester.tap(input); await tester.pump(); expect(tester.widget(input).focusNode?.hasFocus, isTrue); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(sheet, findsOneWidget); expect(composer, findsOneWidget); expect(tester.widget(input).focusNode?.hasFocus, isFalse); await tester.tapAt(const Offset(20, 20)); await tester.pumpAndSettle(); expect(sheet, findsOneWidget); expect(composer, findsNothing); }); testWidgets('should_use_polished_comment_sizes', (tester) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'Great episode')], ), ], ); await _pumpSheet(tester, repository: repository); expect( tester.getSize(find.byKey(const Key('comment-post'))), const Size(38, 38), ); expect( tester.getSize(find.byKey(const Key('comment-input-placeholder'))).height, 44, ); final placeholderLabel = find.byKey( const Key('comment-input-placeholder-label'), ); final placeholderSendIcon = find.descendant( of: find.byKey(const Key('comment-post')), matching: find.byIcon(Icons.send_rounded), ); expect( tester.getCenter(placeholderLabel).dy, closeTo(tester.getCenter(placeholderSendIcon).dy, 1), ); expect(find.byKey(const Key('player-comments-drag-handle')), findsNothing); expect( tester.getSize(find.byKey(const Key('player-comments-close'))), const Size(44, 44), ); expect( find.descendant( of: find.byKey(const Key('player-comments-close')), matching: find.byIcon(Icons.keyboard_arrow_down_rounded), ), findsOneWidget, ); final title = tester.widget( find.byKey(const Key('comment-count-label')), ); expect(title.style?.fontSize, 16); expect(title.style?.fontWeight, FontWeight.w600); expect( tester.getCenter(find.byKey(const Key('comment-count-label'))).dx, closeTo( tester.getCenter(find.byKey(const Key('player-comments-sheet'))).dx, 0.5, ), ); final avatar = tester.widget( find.descendant( of: find.byKey(const Key('comment-item-10')), matching: find.byType(CircleAvatar), ), ); expect(avatar.radius, 20); final body = tester.widget(find.text('Great episode')); expect(body.style?.fontSize, 16); expect(body.style?.height, 1.4); final composer = tester.widget( find.byKey(const Key('comment-input-placeholder')), ); final decoration = composer.decoration! as BoxDecoration; expect(decoration.color, AppColors.commentInput); expect(decoration.borderRadius, BorderRadius.circular(22)); }); testWidgets('should_hide_report_and_delete_actions', (tester) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [ commentFixture(id: '10', content: 'My comment', mine: true), ], ), ], ); await _pumpSheet(tester, repository: repository); expect( find.byWidgetPredicate( (widget) => widget.key is ValueKey && (widget.key! as ValueKey).value.startsWith('comment-menu-'), ), findsNothing, ); expect(find.text('Delete comment'), findsNothing); expect(find.text('Report comment'), findsNothing); expect(find.byIcon(Icons.more_horiz_rounded), findsNothing); }); testWidgets('should_use_half_screen_height_for_one_comment', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'One comment')], ), ], ); await _pumpSheet(tester, repository: repository); final sheet = find.byKey(const Key('player-comments-sheet')); expect(tester.getSize(sheet).height, closeTo(844 / 2, 0.1)); }); testWidgets('should_keep_minimum_height_for_empty_state', (tester) async { final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); expect( tester.getSize(find.byKey(const Key('player-comments-sheet'))).height, greaterThanOrEqualTo(220), ); }); testWidgets('should_keep_long_comment_list_scrollable', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final repository = FakeCommentRepository( pages: [CommentPage(comments: _comments(20))], ); await _pumpSheet(tester, repository: repository); final scrollable = find .descendant( of: find.byKey(const Key('comment-list')), matching: find.byType(Scrollable), ) .first; final position = tester.state(scrollable).position; expect(position.maxScrollExtent, greaterThan(0)); await tester.drag( find.byKey(const Key('comment-list')), const Offset(0, -300), ); await tester.pumpAndSettle(); expect(position.pixels, greaterThan(0)); expect( tester.getSize(find.byKey(const Key('player-comments-sheet'))).height, closeTo(844 / 2, 0.1), ); }); testWidgets('should_hide_comment_like_controls', (tester) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'No likes shown')], ), ], ); await _pumpSheet(tester, repository: repository); expect( find.byWidgetPredicate( (widget) => widget.key is ValueKey && (widget.key! as ValueKey).value.startsWith('comment-like-'), ), findsNothing, ); expect(find.byIcon(Icons.favorite_rounded), findsNothing); expect(find.byIcon(Icons.favorite_border_rounded), findsNothing); }); testWidgets('should_close_sheet_from_down_arrow', (tester) async { final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('player-comments-close'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsNothing); }); testWidgets('should_show_review_badge_and_server_replaced_content', ( tester, ) async { final repository = FakeCommentRepository( pages: [const CommentPage()], publishResult: commentFixture( id: '11', content: 'hello ***', status: 'pending', mine: true, ), ); final haptics = FakeHaptics(); await _pumpSheet(tester, repository: repository, haptics: haptics); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const Key('comment-composer-input')), 'hello badword', ); await tester.pump(); await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(find.text('hello ***'), findsOneWidget); expect(find.text('Under review'), findsOneWidget); expect(haptics.lightImpacts, 1); expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); }); testWidgets('should_send_parent_id_when_replying_to_top_level_comment', ( tester, ) async { final root = commentFixture(id: '10', content: 'Root', replyCount: 0); final repository = FakeCommentRepository( pages: [ CommentPage(comments: [root]), ], publishResult: commentFixture( id: '11', parentId: '10', rootId: '10', content: 'My reply', mine: true, ), ); await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-reply-10'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget); expect(find.textContaining('Replying to'), findsOneWidget); await tester.enterText( find.byKey(const Key('comment-composer-input')), 'My reply', ); await tester.pump(); await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); expect(repository.publishCalls.single.parentId, '10'); expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(find.text('My reply'), findsOneWidget); }); testWidgets('should_show_error_and_retry_when_comment_page_fails', ( tester, ) async { final repository = FakeCommentRepository(pageError: StateError('offline')); await _pumpSheet(tester, repository: repository); expect(find.byKey(const Key('comment-list-error')), findsOneWidget); expect(find.byKey(const Key('comment-list-retry')), findsOneWidget); }); } Future _pumpSheet( WidgetTester tester, { required FakeCommentRepository repository, FakeHaptics? haptics, Locale locale = const Locale('en'), String? episodeId, }) async { final auth = await _auth(); await tester.pumpWidget( MultiProvider( providers: [ Provider.value(value: repository), Provider.value( value: FakeInteractionRepository(), ), ChangeNotifierProvider.value(value: auth), Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, initialRoute: '/comments', routes: { '/': (_) => const Scaffold(backgroundColor: AppColors.ink), '/comments': (_) => Scaffold( backgroundColor: AppColors.ink, resizeToAvoidBottomInset: false, body: Align( alignment: Alignment.bottomCenter, child: DramaCommentsSheet(drama: _drama, episodeId: episodeId), ), ), }, ), ), ); await tester.pumpAndSettle(); return auth; } Future _auth() async { final client = ApiClient( baseUrl: 'http://127.0.0.1:8081', adapter: FakeHttpAdapter((options) async { if (options.uri.path == '/app/auth/login') { return FakeHttpResponse( jsonEncode({ 'code': 0, 'message': 'ok', 'data': { 'accessToken': 'access', 'refreshToken': 'refresh', 'expiresIn': 7200, 'user': {'uid': '9', 'nickname': 'Viewer', 'avatarUrl': ''}, }, }), ); } return FakeHttpResponse('{"code":0,"message":"ok","data":null}'); }), ); final store = AuthSessionStore(); final session = await store.read(); final auth = _TestAuthController( AuthApi(client), store, signedIn: session != null, ); await auth.load(); return auth; } List _comments(int count) => [ for (var index = 0; index < count; index++) commentFixture(id: '${index + 10}', content: 'Comment ${index + 1}'), ]; class _TestAuthController extends AuthController { _TestAuthController(super.api, super.store, {required this._signedIn}); bool _signedIn; @override bool get isSignedIn => _signedIn; void setSignedIn(bool value) { _signedIn = value; notifyListeners(); } }