feat(flutter): 接入互动评论点赞举报

This commit is contained in:
Zhou Weidong committed 2026-10-17 15:26:42 +08:00
1 parent 45f5f25640
commit d361034226
39 files changed
+3298 -462

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/comment_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
String envelope(Object? data) =>
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
Map<String, dynamic> commentJson({
String id = '2111012753262120962',
String content = 'Great episode',
String status = 'published',
bool mine = false,
bool liked = true,
String parentId = '0',
String rootId = '0',
}) => {
'id': id,
'dramaId': '5',
'episodeId': '7',
'parentId': parentId,
'rootId': rootId,
'userId': '2111012753262120001',
'uid': 'viewer-1',
'nickname': 'Viewer',
'avatarUrl': '',
'content': content,
'status': status,
'auditReason': '',
'likeCount': '12',
'replyCount': 2,
'reportCount': '0',
'createdAt': '2026-10-17T12:30:00',
'mine': mine,
'liked': liked,
};
test('should_parse_cursor_page_and_string_ids_when_comment_list_is_loaded', () async {
final adapter = FakeHttpAdapter((options) async {
return FakeHttpResponse(
envelope({
'list': [commentJson()],
'nextCursor': '2111012753262120962',
'hasMore': true,
}),
);
});
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.page(dramaId: '5', cursor: '99', size: 20);
expect(adapter.requests.single.method, 'GET');
expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.queryParameters, {
'dramaId': '5',
'cursor': '99',
'size': '20',
});
expect(page.comments.single.id, '2111012753262120962');
expect(page.comments.single.dramaId, '5');
expect(page.comments.single.episodeId, '7');
expect(page.comments.single.likeCount, 12);
expect(page.nextCursor, '2111012753262120962');
expect(page.hasMore, isTrue);
});
test('should_send_parent_id_only_for_reply_and_parse_replaced_content_when_published', () async {
final adapter = FakeHttpAdapter((options) async {
return FakeHttpResponse(
envelope(
commentJson(
id: '2111012753262120999',
content: 'great ***',
status: 'pending',
mine: true,
parentId: '2111012753262120962',
rootId: '2111012753262120962',
),
),
);
});
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final reply = await api.publish(
dramaId: '5',
content: 'great badword',
parentId: '2111012753262120962',
);
expect(adapter.requests.single.method, 'POST');
expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.data, {
'dramaId': '5',
'content': 'great badword',
'parentId': '2111012753262120962',
});
expect(reply.content, 'great ***');
expect(reply.status, 'pending');
expect(reply.parentId, '2111012753262120962');
expect(reply.rootId, '2111012753262120962');
});
test('should_delete_comment_by_string_id_when_requested', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null)),
);
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
await api.delete('2111012753262120962');
expect(adapter.requests.single.method, 'DELETE');
expect(
adapter.requests.single.uri.path,
'/app/comments/2111012753262120962',
);
});
}
+192
View File
@@ -0,0 +1,192 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/comment.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/services/comment_controller.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
CommentController controller(
FakeCommentRepository comments, {
FakeInteractionRepository? interactions,
}) => CommentController(
comments: comments,
interactions: interactions ?? FakeInteractionRepository(),
dramaId: '5',
);
test('should_append_next_page_with_cursor_when_has_more_is_true', () async {
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [commentFixture(id: '2')],
nextCursor: '2',
hasMore: true,
),
CommentPage(comments: [commentFixture(id: '1')]),
],
);
final model = controller(repository);
await model.load();
await model.loadMore();
expect(model.comments.map((item) => item.id), ['2', '1']);
expect(model.hasMore, isFalse);
expect(repository.pageCalls[0].cursor, isNull);
expect(repository.pageCalls[1].cursor, '2');
expect(repository.pageCalls.every((call) => call.size == 20), isTrue);
model.dispose();
});
test('should_use_server_content_and_pending_status_when_review_is_returned', () async {
final repository = FakeCommentRepository(
publishResult: commentFixture(
id: '3',
content: 'replaced ***',
status: 'pending',
mine: true,
),
);
final model = controller(repository);
final result = await model.publish(' replaced badword ');
expect(result.isSuccess, isTrue);
expect(repository.publishCalls.single.content, 'replaced badword');
expect(model.comments.single.content, 'replaced ***');
expect(model.comments.single.isPending, isTrue);
model.dispose();
});
test('should_expose_backend_message_and_keep_existing_list_when_publish_is_blocked', () async {
final repository = FakeCommentRepository(
publishError: const ApiException(400, '评论包含敏感词'),
);
final model = controller(repository);
await model.load();
final result = await model.publish('blocked');
expect(result.isSuccess, isFalse);
expect(result.errorMessage, '评论包含敏感词');
expect(model.comments, isEmpty);
model.dispose();
});
test('should_insert_reply_under_root_and_request_replies_with_root_id', () async {
final root = commentFixture(id: '10', replyCount: 1);
final reply = commentFixture(
id: '11',
parentId: '10',
rootId: '10',
content: 'Reply',
mine: true,
);
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [root]),
CommentPage(comments: [reply]),
],
publishResult: reply,
);
final model = controller(repository);
await model.load();
await model.loadReplies('10');
final result = await model.publish('Reply', replyTo: root);
expect(result.isSuccess, isTrue);
expect(repository.publishCalls.single.parentId, '10');
expect(repository.pageCalls.last.rootId, '10');
expect(model.repliesFor('10').map((item) => item.id), ['11']);
model.dispose();
});
test('should_restore_comment_at_original_position_when_delete_fails', () async {
final first = commentFixture(id: '2', mine: true);
final second = commentFixture(id: '1');
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [first, second]),
],
deleteError: const ApiException(500, '删除失败'),
);
final model = controller(repository);
await model.load();
final result = await model.delete(first);
expect(result.isSuccess, isFalse);
expect(result.errorMessage, '删除失败');
expect(model.comments.map((item) => item.id), ['2', '1']);
expect(repository.deleteCalls, ['2']);
model.dispose();
});
test('should_remove_own_comment_when_delete_succeeds', () async {
final own = commentFixture(id: '2', mine: true);
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [own]),
],
);
final model = controller(repository);
await model.load();
final result = await model.delete(own);
expect(result.isSuccess, isTrue);
expect(model.comments, isEmpty);
model.dispose();
});
test('should_optimistically_update_comment_like_and_apply_server_count', () async {
final comment = commentFixture(id: '2', likeCount: 3, liked: false);
final interactions = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 8),
);
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [comment]),
],
);
final model = controller(repository, interactions: interactions);
await model.load();
final pending = model.toggleLike(comment);
expect(model.comments.single.liked, isTrue);
expect(model.comments.single.likeCount, 4);
final result = await pending;
expect(result.isSuccess, isTrue);
expect(model.comments.single.liked, isTrue);
expect(model.comments.single.likeCount, 8);
expect(interactions.likeTargets, ['COMMENT:2']);
model.dispose();
});
test('should_rollback_comment_like_when_request_fails', () async {
final comment = commentFixture(id: '2', likeCount: 3, liked: true);
final interactions = FakeInteractionRepository()
..likeError = const ApiException(500, '点赞失败');
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [comment]),
],
);
final model = controller(repository, interactions: interactions);
await model.load();
final result = await model.toggleLike(comment);
expect(result.isSuccess, isFalse);
expect(result.errorMessage, '点赞失败');
expect(model.comments.single.liked, isTrue);
expect(model.comments.single.likeCount, 3);
model.dispose();
});
}
+317
View File
@@ -0,0 +1,317 @@
// Author: fruit.com.cn
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/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_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_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);
await tester.tap(find.byKey(const Key('comment-list-load-more')));
await tester.pumpAndSettle();
expect(find.text('Second page'), findsOneWidget);
expect(repository.pageCalls.last.cursor, '10');
});
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');
});
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,
),
);
await _pumpSheet(tester, repository: repository);
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);
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_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);
await _pumpSheet(
tester,
repository: repository,
interactions: interactions,
);
await tester.tap(find.byKey(const Key('comment-like-10')));
await tester.pumpAndSettle();
expect(interactions.likeTargets, ['COMMENT:10']);
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();
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(interactions.reports.single.targetType, InteractionTargetType.comment);
expect(interactions.reports.single.targetId, '10');
expect(interactions.reports.single.reasonCode, 'SPAM');
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);
});
}
Future<void> _pumpSheet(
WidgetTester tester, {
required FakeCommentRepository repository,
FakeInteractionRepository? interactions,
}) 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),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(
backgroundColor: Color(0xFF232328),
body: DramaCommentsSheet(drama: _drama),
),
),
),
);
await tester.pumpAndSettle();
}
Future<AuthController> _auth() async {
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: FakeHttpAdapter(
(_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'),
),
);
final auth = AuthController(AuthApi(client), AuthSessionStore());
await auth.load();
return auth;
}