// Author: fruit.com.cn import 'dart:convert'; import 'package:flutter/material.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/models/interaction.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() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'test-access', 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); }); 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 input = find.byKey(const Key('comment-input')); final field = tester.widget(input); expect(field.decoration?.hintText, 'Sign in to comment'); expect(field.focusNode?.hasFocus, isFalse); await tester.tap(input); await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); expect(tester.widget(input).focusNode?.hasFocus, isFalse); await tester.tap(find.text('Not now')); await tester.pumpAndSettle(); expect(tester.widget(input).focusNode?.hasFocus, isFalse); }); 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'))); 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.pump(); await tester.pump(const Duration(milliseconds: 300)); expect( tester .widget(find.byKey(const Key('comment-input'))) .focusNode ?.hasFocus, isTrue, ); }); 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(find.byKey(const Key('comment-count-label')), findsNothing); 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_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); await tester.enterText( find.byKey(const Key('comment-input')), 'blocked text', ); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); expect(find.text('评论包含敏感词'), findsOneWidget); final field = tester.widget( find.byKey(const Key('comment-input')), ); expect(field.controller?.text, 'blocked text'); // 发送失败时保留输入与焦点,用户可以直接改完再发。 expect(field.focusNode?.hasFocus, isTrue); }, ); testWidgets('should_unfocus_input_after_successful_post', (tester) async { final repository = FakeCommentRepository( pages: [const CommentPage()], publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true), ); await _pumpSheet(tester, repository: repository); final input = find.byKey(const Key('comment-input')); await tester.tap(input); await tester.pump(); expect(tester.widget(input).focusNode?.hasFocus, isTrue); await tester.enterText(input, 'Nice demo'); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); expect(tester.widget(input).focusNode?.hasFocus, isFalse); }); 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(40, 40), ); expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 52); expect( find.byKey(const Key('player-comments-drag-handle')), findsOneWidget, ); final avatar = tester.widget( find.descendant( of: find.byKey(const Key('comment-item-10')), matching: find.byType(CircleAvatar), ), ); expect(avatar.radius, 16); final body = tester.widget(find.text('Great episode')); expect(body.style?.fontSize, 14); expect(body.style?.height, 1.4); expect( tester .widget(find.byKey(const Key('comment-input'))) .decoration ?.fillColor, AppColors.commentInput, ); }); 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.enterText( find.byKey(const Key('comment-input')), 'hello badword', ); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); expect(find.text('hello ***'), findsOneWidget); expect(find.text('Under review'), findsOneWidget); expect(haptics.lightImpacts, 1); final field = tester.widget( find.byKey(const Key('comment-input')), ); expect(field.controller?.text, isEmpty); }); 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.pump(); expect(find.textContaining('Replying to'), findsOneWidget); await tester.enterText(find.byKey(const Key('comment-input')), 'My reply'); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); expect(repository.publishCalls.single.parentId, '10'); 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); }); testWidgets('should_update_comment_like_count_when_server_returns_count', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'Like me')], ), ], ); final interactions = FakeInteractionRepository() ..nextLikeResult = const LikeToggleResult(liked: true, count: 7); final haptics = FakeHaptics(); await _pumpSheet( tester, repository: repository, interactions: interactions, haptics: haptics, ); await tester.tap(find.byKey(const Key('comment-like-10'))); await tester.pumpAndSettle(); expect(interactions.likeTargets, ['COMMENT:10']); expect(haptics.lightImpacts, 1); expect(find.text('7'), findsOneWidget); }); testWidgets('should_report_comment_and_show_success_when_submitted', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'Report me')], ), ], ); final interactions = FakeInteractionRepository(); final haptics = FakeHaptics(); await _pumpSheet( tester, repository: repository, interactions: interactions, haptics: haptics, ); await tester.tap(find.byKey(const Key('comment-menu-10'))); await tester.pumpAndSettle(); await tester.tap(find.text('Report comment')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('report-submit'))); await tester.pumpAndSettle(); expect( interactions.reports.single.targetType, InteractionTargetType.comment, ); expect(interactions.reports.single.targetId, '10'); expect(interactions.reports.single.reasonCode, 'SPAM'); expect(haptics.lightImpacts, 1); expect(find.text('Report submitted. Thank you.'), findsOneWidget); }); testWidgets('should_show_backend_message_when_comment_report_is_duplicated', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'Report me')], ), ], ); final interactions = FakeInteractionRepository() ..reportError = const ApiException(200003, '你已举报过该内容,我们正在处理'); await _pumpSheet( tester, repository: repository, interactions: interactions, ); await tester.tap(find.byKey(const Key('comment-menu-10'))); await tester.pumpAndSettle(); await tester.tap(find.text('Report comment')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('report-submit'))); await tester.pumpAndSettle(); expect(find.text('你已举报过该内容,我们正在处理'), findsOneWidget); }); testWidgets('should_open_auth_gate_without_reporting_when_signed_out', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final repository = FakeCommentRepository( pages: [ CommentPage( comments: [commentFixture(id: '10', content: 'Report me')], ), ], ); final interactions = FakeInteractionRepository(); await _pumpSheet( tester, repository: repository, interactions: interactions, ); await tester.tap(find.byKey(const Key('comment-menu-10'))); await tester.pumpAndSettle(); await tester.tap(find.text('Report comment')); await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); expect(interactions.reports, isEmpty); }); testWidgets('should_show_backend_message_when_reporting_own_comment', ( tester, ) async { final repository = FakeCommentRepository( pages: [ CommentPage( comments: [ commentFixture(id: '10', content: 'My comment', mine: true), ], ), ], ); final interactions = FakeInteractionRepository() ..reportError = const ApiException(400, '不能举报自己发布的内容'); await _pumpSheet( tester, repository: repository, interactions: interactions, ); await tester.tap(find.byKey(const Key('comment-menu-10'))); await tester.pumpAndSettle(); expect(find.text('Delete comment'), findsOneWidget); expect(find.text('Report comment'), findsOneWidget); await tester.tap(find.text('Report comment')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('report-submit'))); await tester.pumpAndSettle(); expect(find.text('不能举报自己发布的内容'), findsOneWidget); }); } Future _pumpSheet( WidgetTester tester, { required FakeCommentRepository repository, FakeInteractionRepository? interactions, FakeHaptics? haptics, }) async { final auth = await _auth(); await tester.pumpWidget( MultiProvider( providers: [ Provider.value(value: repository), Provider.value( value: interactions ?? FakeInteractionRepository(), ), ChangeNotifierProvider.value(value: auth), Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const Scaffold( backgroundColor: AppColors.ink, body: DramaCommentsSheet(drama: _drama), ), ), ), ); 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; } 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(); } }