diff --git a/test/dashbot/pages/dashbot_chat_page_test.dart b/test/dashbot/pages/dashbot_chat_page_test.dart new file mode 100644 index 00000000..6b104183 --- /dev/null +++ b/test/dashbot/pages/dashbot_chat_page_test.dart @@ -0,0 +1,318 @@ +import 'package:apidash/dashbot/core/constants/constants.dart'; +import 'package:apidash/dashbot/features/chat/models/chat_message.dart'; +import 'package:apidash/dashbot/features/chat/models/chat_state.dart'; +import 'package:apidash/dashbot/features/chat/view/pages/dashbot_chat_page.dart'; +import 'package:apidash/dashbot/features/chat/view/widgets/dashbot_task_buttons.dart'; +import 'package:apidash/dashbot/features/chat/viewmodel/chat_viewmodel.dart'; +import 'package:apidash/providers/collection_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +void main() { + Widget createChatScreen({ + List overrides = const [], + ChatMessageType? initialTask, + }) { + return ProviderScope( + overrides: [ + // Override the selectedRequestModelProvider to prevent Hive dependency issues + selectedRequestModelProvider.overrideWith((ref) => null), + ...overrides, + ], + child: MaterialApp( + home: Scaffold( + body: ChatScreen(initialTask: initialTask), + ), + ), + ); + } + + testWidgets('ChatScreen shows empty-state prompt when idle', (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState()); + return spy; + }), + ], + ), + ); + await tester.pump(); + + expect(find.text('Ask me anything!'), findsOneWidget); + expect(spy.sendMessageCalls, isEmpty); + }); + + testWidgets('ChatScreen triggers initial task without user input', + (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + initialTask: ChatMessageType.generateDoc, + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState()); + return spy; + }), + ], + ), + ); + + await tester.pump(); + + expect(spy.sendMessageCalls.length, 1); + expect(spy.sendMessageCalls.first.text, isEmpty); + expect(spy.sendMessageCalls.first.type, ChatMessageType.generateDoc); + expect(spy.sendMessageCalls.first.countAsUser, isFalse); + }); + + testWidgets('ChatScreen toggles task suggestions panel', (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState()); + return spy; + }), + ], + ), + ); + + expect(find.byType(DashbotTaskButtons), findsNothing); + + await tester.tap(find.byIcon(Icons.help_outline_rounded)); + await tester.pump(); + + expect(find.byType(DashbotTaskButtons), findsOneWidget); + }); + + testWidgets('Clear chat icon delegates to viewmodel', (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState()); + return spy; + }), + ], + ), + ); + + await tester.tap(find.byIcon(Icons.clear_all_rounded)); + await tester.pump(); + + expect(spy.clearCalled, isTrue); + }); + + testWidgets('Submitting text sends general chat message', (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState()); + return spy; + }), + ], + ), + ); + + await tester.enterText(find.byType(TextField), 'Hello Dashbot'); + await tester.tap(find.byIcon(Icons.send_rounded)); + await tester.pump(); + + expect(spy.sendMessageCalls.length, 1); + expect(spy.sendMessageCalls.first.text, 'Hello Dashbot'); + expect(spy.sendMessageCalls.first.type, ChatMessageType.general); + }); + + testWidgets('Streaming state renders temporary ChatBubble', (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState( + isGenerating: true, currentStreamingResponse: 'Streaming...')); + return spy; + }), + ], + ), + ); + + await tester.pump(); + + final markdown = + tester.widget(find.byType(MarkdownBody).first); + expect(markdown.data, 'Streaming...'); + }); + + testWidgets('Existing chat messages render in list', (tester) async { + late SpyChatViewmodel spy; + final messages = [ + ChatMessage( + id: '1', + content: 'First', + role: MessageRole.user, + timestamp: DateTime(2024), + ), + ChatMessage( + id: '2', + content: 'Second', + role: MessageRole.system, + timestamp: DateTime(2024, 2), + ), + ]; + + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setMessages(messages); + spy.setState(ChatState(chatSessions: {'global': messages})); + return spy; + }), + ], + ), + ); + + await tester.pump(); + + expect(find.byType(ListView), findsOneWidget); + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + }); + + testWidgets('TextField onSubmitted sends message', (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState()); + return spy; + }), + ], + ), + ); + + await tester.enterText(find.byType(TextField), 'Test message'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(spy.sendMessageCalls.length, 1); + expect(spy.sendMessageCalls.first.text, 'Test message'); + expect(spy.sendMessageCalls.first.type, ChatMessageType.general); + }); + + testWidgets('Task suggestions panel hides when generating starts', + (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState()); + return spy; + }), + ], + ), + ); + + // First show task suggestions + await tester.tap(find.byIcon(Icons.help_outline_rounded)); + await tester.pump(); + expect(find.byType(DashbotTaskButtons), findsOneWidget); + + // Then start generating - this should hide the task suggestions + spy.setState(const ChatState(isGenerating: true)); + await tester.pump(); + + expect(find.byType(DashbotTaskButtons), findsNothing); + }); + + testWidgets('Scroll animation triggers on streaming response changes', + (tester) async { + late SpyChatViewmodel spy; + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState( + isGenerating: true, + currentStreamingResponse: 'Initial...', + )); + return spy; + }), + ], + ), + ); + + await tester.pump(); + + // Change the streaming response - this should trigger scroll + spy.setState(const ChatState( + isGenerating: true, + currentStreamingResponse: 'Updated streaming response...', + )); + await tester.pump(); + + // Verify scrolling behavior by checking that the new content is rendered + expect(find.text('Updated streaming response...'), findsOneWidget); + }); + + testWidgets('Scroll animation triggers when generation completes', + (tester) async { + late SpyChatViewmodel spy; + final messages = [ + ChatMessage( + id: '1', + content: 'Generated response', + role: MessageRole.system, + timestamp: DateTime(2024), + ), + ]; + + await tester.pumpWidget( + createChatScreen( + overrides: [ + chatViewmodelProvider.overrideWith((ref) { + spy = SpyChatViewmodel(ref); + spy.setState(const ChatState(isGenerating: true)); + return spy; + }), + ], + ), + ); + + await tester.pump(); + + // Complete generation - this should trigger scroll + spy.setMessages(messages); + spy.setState(ChatState( + isGenerating: false, + chatSessions: {'global': messages}, + )); + await tester.pump(); + + expect(find.text('Generated response'), findsOneWidget); + }); +} diff --git a/test/dashbot/pages/dashbot_default_page_test.dart b/test/dashbot/pages/dashbot_default_page_test.dart new file mode 100644 index 00000000..9fbd44c5 --- /dev/null +++ b/test/dashbot/pages/dashbot_default_page_test.dart @@ -0,0 +1,128 @@ +import 'package:apidash/dashbot/core/common/pages/dashbot_default_page.dart'; +import 'package:apidash/dashbot/core/constants/constants.dart'; +import 'package:apidash/dashbot/core/routes/dashbot_routes.dart'; +import 'package:apidash/dashbot/features/home/view/widgets/home_screen_task_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +Finder _taskButton(String snippet) => find.byWidgetPredicate( + (widget) => + widget is HomeScreenTaskButton && widget.label.contains(snippet), + ); + +void main() { + testWidgets('DashbotDefaultPage renders greeting and actions', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: DashbotDefaultPage())); + + expect(find.textContaining('Hello there'), findsOneWidget); + expect(find.textContaining('make one'), findsOneWidget); + expect(find.textContaining('Open Chat'), findsOneWidget); + expect(find.textContaining('Import cURL'), findsOneWidget); + expect(find.textContaining('Import OpenAPI'), findsOneWidget); + }); + + testWidgets('Open Chat button pushes chat route without arguments', + (tester) async { + final observer = RecordingNavigatorObserver(); + Object? capturedArgs; + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + onGenerateRoute: (settings) { + if (settings.name == DashbotRoutes.dashbotChat) { + capturedArgs = settings.arguments; + } + return MaterialPageRoute( + settings: settings, + builder: (_) => const SizedBox.shrink(), + ); + }, + home: const DashbotDefaultPage(), + ), + ); + await tester.pump(); + + final openChatButton = _taskButton('Open Chat'); + expect(openChatButton, findsOneWidget); + await tester.tap(find.descendant( + of: openChatButton, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(observer.lastRoute?.settings.name, DashbotRoutes.dashbotChat); + expect(capturedArgs, isNull); + }); + + group('Import buttons push chat route with correct arguments', () { + testWidgets('Import cURL button', (tester) async { + final observer = RecordingNavigatorObserver(); + Object? capturedArgs; + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + onGenerateRoute: (settings) { + if (settings.name == DashbotRoutes.dashbotChat) { + capturedArgs = settings.arguments; + } + return MaterialPageRoute( + settings: settings, + builder: (_) => const SizedBox.shrink(), + ); + }, + home: const DashbotDefaultPage(), + ), + ); + await tester.pump(); + + final importCurlButton = _taskButton('Import cURL'); + expect(importCurlButton, findsOneWidget); + await tester.tap(find.descendant( + of: importCurlButton, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(observer.lastRoute?.settings.name, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.importCurl); + }); + + testWidgets('Import OpenAPI button', (tester) async { + final observer = RecordingNavigatorObserver(); + Object? capturedArgs; + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + onGenerateRoute: (settings) { + if (settings.name == DashbotRoutes.dashbotChat) { + capturedArgs = settings.arguments; + } + return MaterialPageRoute( + settings: settings, + builder: (_) => const SizedBox.shrink(), + ); + }, + home: const DashbotDefaultPage(), + ), + ); + await tester.pump(); + + final importOpenApiButton = _taskButton('Import OpenAPI'); + expect(importOpenApiButton, findsOneWidget); + await tester.tap(find.descendant( + of: importOpenApiButton, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(observer.lastRoute?.settings.name, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.importOpenApi); + }); + }); +} diff --git a/test/dashbot/pages/dashbot_home_page_test.dart b/test/dashbot/pages/dashbot_home_page_test.dart new file mode 100644 index 00000000..2e7b396e --- /dev/null +++ b/test/dashbot/pages/dashbot_home_page_test.dart @@ -0,0 +1,311 @@ +import 'package:apidash/dashbot/features/home/view/pages/dashbot_home_page.dart'; +import 'package:apidash/dashbot/features/home/view/widgets/home_screen_task_button.dart'; +import 'package:apidash/dashbot/core/constants/constants.dart'; +import 'package:apidash/dashbot/core/providers/dashbot_window_notifier.dart'; +import 'package:apidash/dashbot/core/routes/dashbot_routes.dart'; +import 'package:apidash/models/request_model.dart'; +import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/action_buttons/test_utils.dart'; + +Finder _taskButton(String snippet) => find.byWidgetPredicate( + (widget) => + widget is HomeScreenTaskButton && widget.label.contains(snippet), + ); + +Future _pumpHomePage( + WidgetTester tester, { + RequestModel? selectedModel, + void Function(String? name, Object? arguments)? onRoute, +}) async { + final windowNotifier = RecordingDashbotWindowNotifier(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + dashbotWindowNotifierProvider.overrideWith((ref) => windowNotifier), + selectedRequestModelProvider.overrideWith((ref) => selectedModel), + ], + child: MaterialApp( + onGenerateRoute: (settings) { + onRoute?.call(settings.name, settings.arguments); + return MaterialPageRoute( + settings: settings, + builder: (_) => const SizedBox.shrink(), + ); + }, + home: const Scaffold(body: DashbotHomePage()), + ), + ), + ); + await tester.pumpAndSettle(); + return windowNotifier; +} + +void main() { + testWidgets('DashbotHomePage renders greeting and quick actions', + (tester) async { + await _pumpHomePage(tester, onRoute: (_, __) {}); + + expect(find.textContaining('Hello there'), findsOneWidget); + expect(find.textContaining('How can I help you today'), findsOneWidget); + // Note: 'Chat with Dashbot' is only available in debug mode, so we don't test for it here + expect(find.textContaining('Explain me this response'), findsOneWidget); + expect(find.textContaining('Generate documentation'), findsOneWidget); + }); + + testWidgets('Chat with Dashbot button appears and works in debug mode', + (tester) async { + // In debug mode (which tests run in), the button should be present + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + // The button should be visible in debug mode + final buttonFinder = _taskButton('Chat with Dashbot'); + expect(buttonFinder, findsOneWidget); + + // Tap the button + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + // Should navigate to chat without any initial task arguments + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, isNull); + }); + + group('Quick action buttons navigate with correct arguments', () { + testWidgets('Explain me this response button', (tester) async { + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + final buttonFinder = _taskButton('Explain me this response'); + expect(buttonFinder, findsOneWidget); + + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.explainResponse); + }); + + testWidgets('Help me debug this error button', (tester) async { + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + final buttonFinder = _taskButton('Help me debug this error'); + expect(buttonFinder, findsOneWidget); + + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.debugError); + }); + + testWidgets('Generate documentation button', (tester) async { + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + final buttonFinder = _taskButton('Generate documentation'); + expect(buttonFinder, findsOneWidget); + + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.generateDoc); + }); + + testWidgets('Generate Tests button', (tester) async { + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + final buttonFinder = _taskButton('Generate Tests'); + expect(buttonFinder, findsOneWidget); + + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.generateTest); + }); + + testWidgets('Generate Code button', (tester) async { + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + final buttonFinder = _taskButton('Generate Code'); + expect(buttonFinder, findsOneWidget); + + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.generateCode); + }); + + testWidgets('Import cURL button', (tester) async { + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + final buttonFinder = _taskButton('Import cURL'); + expect(buttonFinder, findsOneWidget); + + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.importCurl); + }); + + testWidgets('Import OpenAPI button', (tester) async { + String? capturedRoute; + Object? capturedArgs; + + await _pumpHomePage( + tester, + onRoute: (name, arguments) { + capturedRoute = name; + capturedArgs = arguments; + }, + ); + + final buttonFinder = _taskButton('Import OpenAPI'); + expect(buttonFinder, findsOneWidget); + + await tester.tap(find.descendant( + of: buttonFinder, + matching: find.byType(TextButton), + )); + await tester.pumpAndSettle(); + + expect(capturedRoute, DashbotRoutes.dashbotChat); + expect(capturedArgs, ChatMessageType.importOpenApi); + }); + }); + + testWidgets( + 'Generate Tool hides and shows dashbot window even without response', + (tester) async { + final notifier = await _pumpHomePage(tester, onRoute: (_, __) {}); + + await tester.tap(find.text('🛠️ Generate Tool')); + await tester.pumpAndSettle(); + + expect(notifier.hideCalls, 1); + expect(notifier.showCalls, 1); + }); + + testWidgets('Generate UI opens dialog and restores dashbot window', + (tester) async { + final responseModel = const HttpResponseModel( + body: 'example response', + formattedBody: 'formatted', + ); + final requestModel = RequestModel( + id: 'req-1', + httpRequestModel: const HttpRequestModel(), + httpResponseModel: responseModel, + ); + + final notifier = await _pumpHomePage( + tester, + selectedModel: requestModel, + onRoute: (_, __) {}, + ); + + await tester.tap(find.text('📱 Generate UI')); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + + final dialogElement = find.byType(Dialog); + if (dialogElement.evaluate().isNotEmpty) { + Navigator.of(dialogElement.evaluate().first).pop(); + await tester.pumpAndSettle(); + } + + expect(notifier.hideCalls, 1); + expect(notifier.showCalls, 1); + }); +} diff --git a/test/dashbot/pages/test_utils.dart b/test/dashbot/pages/test_utils.dart new file mode 100644 index 00000000..457efe3b --- /dev/null +++ b/test/dashbot/pages/test_utils.dart @@ -0,0 +1,51 @@ +import 'package:apidash/dashbot/features/chat/models/chat_message.dart'; +import 'package:apidash/dashbot/features/chat/viewmodel/chat_viewmodel.dart'; +import 'package:apidash/dashbot/features/chat/models/chat_state.dart'; +import 'package:apidash/dashbot/core/constants/constants.dart'; +import 'package:flutter/material.dart'; + +class SpyChatViewmodel extends ChatViewmodel { + SpyChatViewmodel(super.ref); + + final List<({String text, ChatMessageType type, bool countAsUser})> + sendMessageCalls = []; + + bool clearCalled = false; + List _messages = const []; + + void setMessages(List messages) { + _messages = messages; + state = state.copyWith(chatSessions: {'global': messages}); + } + + void setState(ChatState newState) { + state = newState; + } + + @override + List get currentMessages => _messages; + + @override + Future sendMessage({ + required String text, + ChatMessageType type = ChatMessageType.general, + bool countAsUser = true, + }) async { + sendMessageCalls.add((text: text, type: type, countAsUser: countAsUser)); + } + + @override + void clearCurrentChat() { + clearCalled = true; + } +} + +class RecordingNavigatorObserver extends NavigatorObserver { + Route? lastRoute; + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + lastRoute = route; + } +}