Files
ReelShort/test/comment/drama_comments_sheet_test.dart
T

618 lines
20 KiB
Dart

// 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<TextField>(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<TextField>(input).focusNode?.hasFocus, isFalse);
await tester.tap(find.text('Not now'));
await tester.pumpAndSettle();
expect(tester.widget<TextField>(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<TextField>(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(
tester.widget<Text>(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_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<EmptyState>(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<TextField>(
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),
countResult: 1,
);
await _pumpSheet(tester, repository: repository);
final input = find.byKey(const Key('comment-input'));
await tester.tap(input);
await tester.pump();
expect(tester.widget<TextField>(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<TextField>(input).focusNode?.hasFocus, isFalse);
expect(repository.countCalls, ['5', '5']);
});
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'))).height, 44);
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<Text>(
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<CircleAvatar>(
find.descendant(
of: find.byKey(const Key('comment-item-10')),
matching: find.byType(CircleAvatar),
),
);
expect(avatar.radius, 20);
final body = tester.widget<Text>(find.text('Great episode'));
expect(body.style?.fontSize, 16);
expect(body.style?.height, 1.4);
final composer = tester.widget<Container>(
find.byKey(const Key('comment-composer')),
);
final decoration = composer.decoration! as BoxDecoration;
expect(decoration.color, AppColors.commentInput);
expect(decoration.borderRadius, BorderRadius.circular(22));
expect(
find.descendant(
of: find.byKey(const Key('comment-menu-10')),
matching: find.byIcon(Icons.more_horiz_rounded),
),
findsOneWidget,
);
});
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<String> &&
(widget.key! as ValueKey<String>).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.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<TextField>(
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_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<AuthController> _pumpSheet(
WidgetTester tester, {
required FakeCommentRepository repository,
FakeInteractionRepository? interactions,
FakeHaptics? haptics,
}) async {
final auth = await _auth();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<CommentRepository>.value(value: repository),
Provider<InteractionRepository>.value(
value: interactions ?? FakeInteractionRepository(),
),
ChangeNotifierProvider<AuthController>.value(value: auth),
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
initialRoute: '/comments',
routes: {
'/': (_) => const Scaffold(backgroundColor: AppColors.ink),
'/comments': (_) => const Scaffold(
backgroundColor: AppColors.ink,
body: DramaCommentsSheet(drama: _drama),
),
},
),
),
);
await tester.pumpAndSettle();
return auth;
}
Future<AuthController> _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();
}
}