test: simple ai chat tests (#7901)

* fix: repeated setState in animation list

* test: add simple chat integration test

* chore: update chat list

* test: send messages without default messages

* fix: refresh space after moving page
This commit is contained in:
Lucas 2025-05-09 21:40:06 +08:00 committed by GitHub
parent 7f7657edc8
commit c79ac3290e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 360 additions and 31 deletions

View File

@ -0,0 +1,264 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/ai_test_op.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('chat page:', () {
testWidgets('send messages with default messages', (tester) async {
skipAIChatWelcomePage = true;
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a chat page
final pageName = 'Untitled';
await tester.createNewPageWithNameUnderParent(
name: pageName,
layout: ViewLayoutPB.Chat,
openAfterCreated: false,
);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final userId = '457037009907617792';
final user = User(id: userId, lastName: 'Lucas');
final aiUserId = '0';
final aiUser = User(id: aiUserId, lastName: 'AI');
await tester.loadDefaultMessages(
[
Message.text(
id: '1746776401',
text: 'How to use Kanban to manage tasks?',
author: user,
createdAt: DateTime.now().add(const Duration(seconds: 1)),
),
Message.text(
id: '1746776401_ans',
text:
'I couldnt find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now().add(const Duration(seconds: 2)),
),
Message.text(
id: '1746776402',
text: 'How to use Kanban to manage tasks?',
author: user,
createdAt: DateTime.now().add(const Duration(seconds: 3)),
),
Message.text(
id: '1746776402_ans',
text:
'I couldnt find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now().add(const Duration(seconds: 4)),
),
].reversed.toList(),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// start chat
final int messageId = 1;
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text: '''# How to Use AppFlowy
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
- Create an account or sign in when you first launch the app
- The main interface shows your workspace with a sidebar for navigation and a content area''',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
final chatBloc = tester.getCurrentChatBloc();
expect(chatBloc.chatController.messages.length, equals(6));
});
testWidgets('send messages without default messages', (tester) async {
skipAIChatWelcomePage = true;
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a chat page
final pageName = 'Untitled';
await tester.createNewPageWithNameUnderParent(
name: pageName,
layout: ViewLayoutPB.Chat,
openAfterCreated: false,
);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final userId = '457037009907617792';
final user = User(id: userId, lastName: 'Lucas');
final aiUserId = '0';
final aiUser = User(id: aiUserId, lastName: 'AI');
// start chat
int messageId = 1;
// round 1
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text: '''# How to Use AppFlowy
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
- Create an account or sign in when you first launch the app
- The main interface shows your workspace with a sidebar for navigation and a content area''',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 2
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'I couldnt find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 3
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'What document formatting options are available?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# AppFlowy Document Formatting\n- Basic formatting: Bold, italic, underline, strikethrough\n- Headings: 6 levels of headings for structuring content\n- Lists: Bullet points, numbered lists, and checklists\n- Code blocks: Format text as code with syntax highlighting\n- Tables: Create and format data tables\n- Embedded content: Add images, files, and other rich media',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 4
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How do I export my data from AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# Exporting from AppFlowy\n- Export documents in multiple formats: Markdown, HTML, PDF\n- Export databases as CSV or Excel files\n- Batch export entire workspaces for backup\n- Use the export menu (three dots → Export) on any page\n- Exported files maintain most formatting and structure',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 5
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'Is there a mobile version of AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# AppFlowy on Mobile\n- Yes, AppFlowy is available for iOS and Android devices\n- Download from the App Store or Google Play Store\n- Mobile app includes core functionality: document editing, databases, and boards\n- Offline mode allows working without internet connection\n- Sync automatically when you reconnect\n- Responsive design adapts to different screen sizes',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
final chatBloc = tester.getCurrentChatBloc();
expect(chatBloc.chatController.messages.length, equals(10));
});
});
}

View File

@ -1,5 +1,6 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/chat/chat_page_test.dart' as chat_page_test;
import 'desktop/database/database_icon_test.dart' as database_icon_test;
import 'desktop/first_test/first_test.dart' as first_test;
import 'desktop/uncategorized/code_block_language_selector_test.dart'
@ -17,4 +18,5 @@ Future<void> runIntegration9OnDesktop() async {
tabs_test.main();
code_language_selector.main();
database_icon_test.main();
chat_page_test.main();
}

View File

@ -1,9 +1,14 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test/util.dart';
import 'util.dart';
extension AppFlowyAITest on WidgetTester {
@ -38,4 +43,27 @@ extension AppFlowyAITest on WidgetTester {
testTextInput.enterText(text);
await pumpAndSettle(const Duration(milliseconds: 300));
}
ChatBloc getCurrentChatBloc() {
return element(find.byType(ChatContentPage)).read<ChatBloc>();
}
Future<void> loadDefaultMessages(List<Message> messages) async {
final chatBloc = getCurrentChatBloc();
chatBloc.add(ChatEvent.didLoadLatestMessages(messages));
await blocResponseFuture();
}
Future<void> sendUserMessage(Message message) async {
final chatBloc = getCurrentChatBloc();
// using received message to simulate the user message
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}
Future<void> receiveAIMessage(Message message) async {
final chatBloc = getCurrentChatBloc();
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}
}

View File

@ -1051,6 +1051,8 @@ extension ViewLayoutPBTest on ViewLayoutPB {
return LocaleKeys.document_menuName.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.calendar_menuName.tr();
case ViewLayoutPB.Chat:
return LocaleKeys.chat_newChat.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}

View File

@ -82,7 +82,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
await event.when(
// Loading messages
didLoadLatestMessages: (List<Message> messages) async {
Log.debug(
"[ChatBloc] did load latest messages: ${messages.length}",
);
for (final message in messages) {
Log.debug("[ChatBloc] insert message: ${message.toJson()}");
await chatController.insert(message, index: 0);
}
@ -160,6 +165,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatController.insert(message);
},
receiveMessage: (Message message) {
Log.debug("[ChatBloc] receive message: ${message.toJson()}");
final oldMessage = chatController.messages
.firstWhereOrNull((m) => m.id == message.id);
if (oldMessage == null) {

View File

@ -1,9 +1,9 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:math';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy_backend/log.dart';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
@ -72,6 +72,10 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
duration: const Duration(milliseconds: 200),
);
int initialScrollIndex = 0;
double initialAlignment = 1.0;
List<Message> messages = [];
@override
void initState() {
super.initState();
@ -79,6 +83,9 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
setState(() {
messages = _chatController.messages;
});
switch (event.type) {
case ChatOperationType.insert:
assert(
@ -89,6 +96,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
@ -101,6 +109,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
event.message != null,
'Message must be provided when removing a message.',
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
@ -124,6 +133,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
}
});
messages = _chatController.messages;
_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
@ -158,9 +169,9 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
final height = MediaQuery.of(context).size.height;
// A trick to avoid the first message being scrolled to the top
int initialScrollIndex = _chatController.messages.length;
double initialAlignment = 1.0;
if (_chatController.messages.length <= 2) {
initialScrollIndex = messages.length;
initialAlignment = 1.0;
if (messages.length <= 2) {
initialScrollIndex = 0;
initialAlignment = 0.0;
}
@ -177,13 +188,18 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
physics: ClampingScrollPhysics(),
shrinkWrap: true,
// the extra item is a vertical padding.
itemCount: _chatController.messages.length + 1,
itemCount: messages.length + 1,
itemBuilder: (context, index) {
if (index == _chatController.messages.length) {
return VSpace(height - 360);
if (index < 0 || index > messages.length) {
Log.error('[chat animation list] index out of range: $index');
return const SizedBox.shrink();
}
final message = _chatController.messages[index];
if (index == messages.length) {
return VSpace(height - 400);
}
final message = messages[index];
return widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
@ -211,15 +227,19 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return child;
}
void _scrollLastMessageToTop(Message data) {
Future<void> _scrollLastUserMessageToTop() async {
final user = Provider.of<User>(context, listen: false);
final lastUserMessageIndex = _chatController.messages.lastIndexWhere(
final lastUserMessageIndex = messages.lastIndexWhere(
(message) => message.author.id == user.id,
);
if (lastUserMessageIndex == -1) {
return;
}
if (_lastUserMessageIndex != lastUserMessageIndex) {
// scroll the current message to the top
itemScrollController.scrollTo(
await itemScrollController.scrollTo(
index: lastUserMessageIndex,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
@ -237,7 +257,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
await _scrollToBottomController.reverse();
await itemScrollController.scrollTo(
index: _chatController.messages.length + 1,
index: messages.length + 1,
alignment: 1.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
@ -260,8 +280,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return;
}
if (maxItem.index > _chatController.messages.length - 1 ||
(maxItem.index == _chatController.messages.length - 1 &&
if (maxItem.index > messages.length - 1 ||
(maxItem.index == messages.length - 1 &&
maxItem.itemTrailingEdge <= 1.01)) {
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.reverse();
@ -292,22 +312,14 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
}
void _onInserted(final int position, final Message data) {
Future<void> _onInserted(final int position, final Message data) async {
// scroll the last user message to the top if it's the last message
if (position == _oldList.length) {
_scrollLastMessageToTop(data);
await _scrollLastUserMessageToTop();
}
}
void _onRemoved(final int position, final Message data) {}
void _onChanged(int position, Message oldData, Message newData) {}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
}

View File

@ -9,6 +9,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
@visibleForTesting
bool skipAIChatWelcomePage = false;
class ChatAnimationListWidget extends StatefulWidget {
const ChatAnimationListWidget({
super.key,
@ -34,7 +37,7 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
final bloc = context.read<ChatBloc>();
// this logic is quite weird, why don't we just get the message from the state?
if (bloc.chatController.messages.isEmpty) {
if (bloc.chatController.messages.isEmpty && !skipAIChatWelcomePage) {
return ChatWelcomePage(
userProfile: widget.userProfile,
onSelectedQuestion: (question) {
@ -70,6 +73,9 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
)
@ -80,6 +86,9 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
);

View File

@ -45,6 +45,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
}
Widget _buildBody(BuildContext context) {
final bool enableAnimation = true;
return Expanded(
child: Align(
alignment: Alignment.topCenter,
@ -70,6 +71,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
message: message,
userProfile: userProfile,
view: view,
enableAnimation: enableAnimation,
),
chatMessageBuilder: (
context,
@ -100,6 +102,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
userProfile: userProfile,
scrollController: scrollController,
itemBuilder: itemBuilder,
enableReversedList: !enableAnimation,
),
),
),

View File

@ -302,7 +302,7 @@ class DesktopHomeScreen extends StatelessWidget {
ancestors.items.firstWhereOrNull((ancestor) => ancestor.isSpace),
(error) => null,
);
switchToSpaceIdNotifier.value = space;
switchToSpaceNotifier.value = space;
}
}

View File

@ -19,7 +19,7 @@ typedef KeyDownHandler = void Function(HotKey hotKey);
ValueNotifier<int> switchToTheNextSpace = ValueNotifier(0);
ValueNotifier<int> createNewPageNotifier = ValueNotifier(0);
ValueNotifier<ViewPB?> switchToSpaceIdNotifier = ValueNotifier(null);
ValueNotifier<ViewPB?> switchToSpaceNotifier = ValueNotifier(null);
@visibleForTesting
final zoomInKeyCodes = [KeyCode.equal, KeyCode.numpadAdd, KeyCode.add];

View File

@ -75,7 +75,7 @@ class _SpaceState extends State<_Space> {
super.initState();
switchToTheNextSpace.addListener(_switchToNextSpace);
switchToSpaceIdNotifier.addListener(_switchToSpace);
switchToSpaceNotifier.addListener(_switchToSpace);
}
@override
@ -184,7 +184,7 @@ class _SpaceState extends State<_Space> {
return;
}
final space = switchToSpaceIdNotifier.value;
final space = switchToSpaceNotifier.value;
if (space == null) {
return;
}

View File

@ -15,6 +15,7 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
@ -887,6 +888,8 @@ void moveViewCrossSpace(
'Move view(${from.name}) to another space(${toSpace.name}), unpublish the view',
);
context.read<ViewBloc>().add(const ViewEvent.unpublish(sync: false));
switchToSpaceNotifier.value = toSpace;
}
context.read<ViewBloc>().add(ViewEvent.move(from, toId, null, null, null));