import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:record/record.dart'; import 'dart:async'; import 'package:flutter_app/core/network/api_repository.dart'; import 'package:flutter_app/core/settings/local_settings_store.dart'; import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart'; import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart'; import 'package:flutter_app/features/audio/presentation/cubit/voice_cubit.dart'; import 'package:flutter_app/main.dart'; class _TestApiRepository extends ApiRepository { _TestApiRepository() : super(baseUrl: 'http://127.0.0.1:8000'); @override Future getModelName() async => 'gemma4:e2b'; @override Future> getAvailableModels() async => const [ ModelDescriptor( id: 'gemma4:e2b', capabilities: ['completion', 'vision', 'audio', 'tools'], capabilitiesVerified: true, ), ]; @override Future> getAgentSkills() async => const [ AgentSkillDescriptor( id: 'code_explain', name: 'شرح الكود', description: 'شرح المصدر.', ), AgentSkillDescriptor( id: 'code_review', name: 'مراجعة الكود', description: 'مراجعة المصدر.', ), AgentSkillDescriptor( id: 'test_plan', name: 'خطة اختبارات', description: 'اقتراح الاختبارات.', ), ]; @override Future> listWorkspaceFiles(String workspacePath) async => [ 'lib/main.dart', ]; @override Future> registerWorkspaceProject(String workspacePath) async => listWorkspaceFiles(workspacePath); @override Future unregisterWorkspaceProject(String workspacePath) async {} @override Future> listConversations() async => []; } class _CancellableWidgetApi extends _TestApiRepository { final controller = StreamController(); @override Future saveConversation(SavedConversation conversation) async {} @override Stream completeStream(List messages, {String? model}) => controller.stream; @override void cancelActiveRequest() { controller.close(); } } class _MarkdownWidgetApi extends _TestApiRepository { @override Future saveConversation(SavedConversation conversation) async {} @override Stream completeStream(List messages, {String? model}) => Stream.value('''| Feature | Status | | --- | --- | | Markdown | **ready** | 1. Parent item - Nested item - [x] GFM task'''); } class _ProposalWidgetApi extends _TestApiRepository { int proposalCount = 0; int applyCount = 0; @override Future saveConversation(SavedConversation conversation) async {} @override Future> listWorkspaceFiles(String workspacePath) async => [ 'src/hello.py', ]; @override Future> registerWorkspaceProject(String workspacePath) async => listWorkspaceFiles(workspacePath); @override Future unregisterWorkspaceProject(String workspacePath) async {} @override Future runAgent( String task, { String? model, String? workspacePath, List workspaceFiles = const [], String? skillId, void Function(String message)? onProgress, }) async { onProgress?.call('يبني معاينة diff دون الكتابة إلى الملف.'); proposalCount++; return AgentRunResult( answer: 'حضّرت معاينة، ولم أكتب الملف.', proposal: FileChangeProposal( token: 'proposal-$proposalCount', path: 'src/hello.py', operation: 'create', diff: '--- /dev/null\n+++ b/src/hello.py\n+print("hello")', expiresInSeconds: 600, ), ); } @override Future applyFileChange(FileChangeProposal proposal) async { applyCount++; return 'أُنشئ الملف ${proposal.path} بعد موافقتك.'; } } void main() { testWidgets('chat page fits a compact Windows window', (tester) async { tester.view.physicalSize = const Size(800, 600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final api = _TestApiRepository(); final chatCubit = ChatCubit( api, settingsStore: LocalSettingsStore.inMemory(), ); await tester.pumpWidget( MultiRepositoryProvider( providers: [RepositoryProvider.value(value: api)], child: MultiBlocProvider( providers: [ BlocProvider.value(value: chatCubit), BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())), ], child: const SovereignApp(), ), ), ); await tester.pumpAndSettle(); expect(find.text('مساعدك المحلي'), findsOneWidget); expect(find.text('مشاريعي'), findsOneWidget); expect(find.byTooltip('تسجيل مشروع جديد'), findsOneWidget); expect(tester.takeException(), isNull); await chatCubit.setDarkMode(true); await tester.pumpAndSettle(); expect( tester.widget(find.byType(MaterialApp)).themeMode, ThemeMode.dark, ); expect(tester.takeException(), isNull); await chatCubit.close(); }); testWidgets('project registration stays visible when sidebar is hidden', ( tester, ) async { tester.view.physicalSize = const Size(700, 600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final api = _TestApiRepository(); final chatCubit = ChatCubit( api, settingsStore: LocalSettingsStore.inMemory(), ); await tester.pumpWidget( MultiRepositoryProvider( providers: [RepositoryProvider.value(value: api)], child: MultiBlocProvider( providers: [ BlocProvider.value(value: chatCubit), BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())), ], child: const SovereignApp(), ), ), ); await tester.pumpAndSettle(); expect(find.text('مشاريعي'), findsNothing); expect(find.byTooltip('تسجيل مشروع من مجلد موجود'), findsOneWidget); expect(tester.takeException(), isNull); await chatCubit.close(); }); testWidgets('model selector displays capabilities reported by Ollama', ( tester, ) async { tester.view.physicalSize = const Size(1000, 700); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final api = _TestApiRepository(); final chatCubit = ChatCubit( api, settingsStore: LocalSettingsStore.inMemory(), ); await tester.pumpWidget( MultiRepositoryProvider( providers: [RepositoryProvider.value(value: api)], child: MultiBlocProvider( providers: [ BlocProvider.value(value: chatCubit), BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())), ], child: const SovereignApp(), ), ), ); await tester.pumpAndSettle(); await chatCubit.selectWorkspace('C:/sample-project'); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('اختيار مهارة للوكيل')); await tester.pumpAndSettle(); expect(find.text('خطة اختبارات'), findsOneWidget); await tester.tap(find.text('مراجعة الكود')); await tester.pumpAndSettle(); expect(chatCubit.state.selectedSkillId, 'code_review'); await tester.tap(find.byType(DropdownButton)); await tester.pumpAndSettle(); expect(find.text('نص · صور · صوت · أدوات'), findsOneWidget); expect(tester.takeException(), isNull); await chatCubit.close(); }); testWidgets('stop button cancels an active answer', (tester) async { tester.view.physicalSize = const Size(1000, 700); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final api = _CancellableWidgetApi(); final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory()); await tester.pumpWidget( MultiRepositoryProvider( providers: [RepositoryProvider.value(value: api)], child: MultiBlocProvider( providers: [ BlocProvider.value(value: cubit), BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())), ], child: const SovereignApp(), ), ), ); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'أوقف هذا الرد'); await tester.tap(find.byTooltip('إرسال')); await tester.pump(); await tester.pump(const Duration(milliseconds: 30)); expect(find.byTooltip('إيقاف التوليد'), findsOneWidget); await tester.tap(find.byTooltip('إيقاف التوليد')); await tester.pump(); await tester.pumpAndSettle(const Duration(milliseconds: 50)); expect(cubit.state.requestStatus, RequestStatus.cancelled); expect(find.byTooltip('إرسال'), findsOneWidget); expect(tester.takeException(), isNull); await cubit.close(); await api.controller.close(); }); testWidgets('assistant renders tables and nested GFM task lists', ( tester, ) async { tester.view.physicalSize = const Size(1000, 700); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final api = _MarkdownWidgetApi(); await tester.pumpWidget( MultiRepositoryProvider( providers: [RepositoryProvider.value(value: api)], child: MultiBlocProvider( providers: [ BlocProvider( create: (_) => ChatCubit( api, settingsStore: LocalSettingsStore.inMemory(), ), ), BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())), ], child: const SovereignApp(), ), ), ); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'اختبار Markdown'); await tester.tap(find.byTooltip('إرسال')); await tester.pumpAndSettle(); expect(find.byType(Table), findsOneWidget); expect(find.text('Feature'), findsOneWidget); expect( find.byWidgetPredicate( (widget) => widget is RichText && widget.text.toPlainText().contains('Nested item'), ), findsOneWidget, ); expect(find.text('☑'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('file changes require explicit review before apply', ( tester, ) async { tester.view.physicalSize = const Size(1100, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final api = _ProposalWidgetApi(); final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory()); await cubit.selectWorkspace(r'C:\Projects\sample'); cubit.selectWorkspaceFiles(['src/hello.py']); await tester.pumpWidget( MultiRepositoryProvider( providers: [RepositoryProvider.value(value: api)], child: MultiBlocProvider( providers: [ BlocProvider.value(value: cubit), BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())), ], child: const SovereignApp(), ), ), ); await tester.pumpAndSettle(); Future requestProposal() async { await tester.enterText(find.byType(TextField), 'أنشئ ملف Python'); await tester.tap(find.byTooltip('إرسال')); await tester.pumpAndSettle(); } await requestProposal(); expect(find.text('مراجعة إنشاء الملف'), findsOneWidget); expect(find.text('src/hello.py'), findsOneWidget); expect(api.applyCount, 0); await tester.tap(find.text('إلغاء')); await tester.pumpAndSettle(); expect(api.applyCount, 0); await requestProposal(); expect(find.text('مراجعة إنشاء الملف'), findsOneWidget); await tester.tap(find.text('إنشاء الملف')); await tester.pumpAndSettle(); expect(api.applyCount, 1); expect(tester.takeException(), isNull); await cubit.close(); }); }