import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; 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'; class _VersionTestApi extends ApiRepository { _VersionTestApi() : super(baseUrl: 'http://127.0.0.1:8000'); final responses = >[ ['الجواب الأول'], ['الجواب الثاني'], ]; SavedConversation? lastSaved; String? indexedWorkspace; List indexedFiles = const []; List deletedFiles = const []; @override Future getModelName() async => 'gemma4:e2b'; @override Future> getAvailableModels() async => const [ ModelDescriptor( id: 'gemma4:e2b', capabilities: ['completion', 'vision', 'audio', 'tools'], capabilitiesVerified: true, ), ModelDescriptor( id: 'granite-embedding:278m', capabilities: ['embedding'], 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> listConversations() async => []; @override Future> listWorkspaceFiles(String workspacePath) async => [ 'README.md', ]; @override Future> indexWorkspaceKnowledge( String workspacePath, List files, ) async { indexedWorkspace = workspacePath; indexedFiles = files; return { 'indexed': [ for (final file in files) {'path': file, 'chunks': 2}, ], }; } @override Future> deleteWorkspaceKnowledge( String workspacePath, List files, ) async { deletedFiles = files; return { 'deleted': [ for (final file in files) {'path': file, 'deleted': true}, ], }; } @override Future saveConversation(SavedConversation conversation) async { lastSaved = conversation; } @override Stream completeStream(List messages, {String? model}) => Stream.fromIterable(responses.removeAt(0)); } class _CancellableTestApi extends _VersionTestApi { final controller = StreamController(); var cancelled = false; final storedConversation = SavedConversation( id: 'conversation', title: 'سؤال قديم', updatedAt: DateTime.utc(2026), messages: const [ ChatMessage('سؤال قديم', fromUser: true), ChatMessage( 'إجابة محفوظة', fromUser: false, answerVersions: ['إجابة محفوظة'], ), ], ); @override Future getConversation(String id) async => storedConversation; @override Stream completeStream(List messages, {String? model}) => controller.stream; @override void cancelActiveRequest() { cancelled = true; controller.close(); } } class _MemorySettingsStore extends LocalSettingsStore { final values = {}; @override bool get persistenceAvailable => true; @override Future> read() async => Map.of(values); @override Future update(Map updates) async { values.addAll(updates); return true; } } void main() { test('embedding-only models are not selectable for chat', () async { final api = _VersionTestApi(); final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory()); await Future.delayed(Duration.zero); expect(cubit.state.availableModels.map((model) => model.id), [ 'gemma4:e2b', ]); await cubit.close(); }); test('selected workspace files can be indexed as local knowledge', () async { final api = _VersionTestApi(); final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory()); await Future.delayed(Duration.zero); await cubit.selectWorkspace(r'C:\Projects\sample'); cubit.selectWorkspaceFiles(['README.md']); final message = await cubit.indexSelectedWorkspaceKnowledge(); expect(api.indexedWorkspace, r'C:\Projects\sample'); expect(api.indexedFiles, ['README.md']); expect(message, contains('2 مقطعًا')); expect(cubit.state.agentProgress, isNull); final removal = await cubit.removeSelectedWorkspaceKnowledge(); expect(api.deletedFiles, ['README.md']); expect(removal, contains('أُزيل 1 ملف')); await cubit.close(); }); test('regeneration keeps prior answers and selection is persisted', () async { final api = _VersionTestApi(); final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory()); await Future.delayed(Duration.zero); await cubit.send('ما معنى API؟'); expect(cubit.state.messages.last.answerVersions, ['الجواب الأول']); expect(cubit.state.modelStatus, ModelConnectionStatus.connected); expect(cubit.state.requestStatus, RequestStatus.completed); expect(cubit.state.lastResponseDuration, isNotNull); await cubit.regenerateLastAnswer(); expect(cubit.state.messages.last.text, 'الجواب الثاني'); expect(cubit.state.messages.last.answerVersions, [ 'الجواب الأول', 'الجواب الثاني', ]); expect(cubit.state.messages.last.selectedVersion, 1); await cubit.selectAnswerVersion(1, 0); expect(cubit.state.messages.last.text, 'الجواب الأول'); expect(cubit.state.messages.last.selectedVersion, 0); expect(api.lastSaved?.messages.last.answerVersions, [ 'الجواب الأول', 'الجواب الثاني', ]); expect(api.lastSaved?.messages.last.selectedVersion, 0); await cubit.close(); }); test( 'local API, model, and notification settings survive Cubit restart', () async { final settings = _MemorySettingsStore(); final firstApi = _VersionTestApi(); final first = ChatCubit(firstApi, settingsStore: settings); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); expect(await first.setApiBaseUrl('file:///unsafe'), isFalse); expect(firstApi.baseUrl, 'http://127.0.0.1:8000'); expect(await first.setApiBaseUrl('http://127.0.0.1:9123'), isTrue); await first.setNotificationsEnabled(false); await first.setDarkMode(true); first.selectModel('gemma4:e2b'); await Future.delayed(Duration.zero); await first.close(); final restoredApi = _VersionTestApi(); final restored = ChatCubit(restoredApi, settingsStore: settings); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); expect(restored.state.apiBaseUrl, 'http://127.0.0.1:9123'); expect(restored.state.notificationsEnabled, isFalse); expect(restored.state.isDarkMode, isTrue); expect(restored.state.selectedModel, 'gemma4:e2b'); await Future.delayed(Duration.zero); restored.selectAgentSkill('code_review'); expect(restored.state.selectedSkillId, 'code_review'); restored.selectAgentSkill('unknown'); expect(restored.state.selectedSkillId, 'code_review'); restored.selectAgentSkill(null); expect(restored.state.selectedSkillId, isNull); await restored.close(); }, ); test( 'cancelling generation preserves partial text and records duration', () async { final api = _CancellableTestApi(); final cubit = ChatCubit( api, settingsStore: LocalSettingsStore.inMemory(), ); await Future.delayed(Duration.zero); final send = cubit.send('اكتب فقرة قصيرة'); await Future.delayed(const Duration(milliseconds: 20)); api.controller.add('بداية الإجابة'); await Future.delayed(Duration.zero); await cubit.cancelGeneration(); await send; expect(api.cancelled, isTrue); expect(cubit.state.isSending, isFalse); expect(cubit.state.requestStatus, RequestStatus.cancelled); expect(cubit.state.messages.last.text, 'بداية الإجابة'); expect(cubit.state.lastResponseDuration, isNotNull); expect(api.lastSaved?.messages.last.text, 'بداية الإجابة'); await cubit.close(); await api.controller.close(); }, ); test( 'cancelling regeneration before its first token keeps saved answer', () async { final api = _CancellableTestApi(); final cubit = ChatCubit( api, settingsStore: LocalSettingsStore.inMemory(), ); await Future.delayed(Duration.zero); await cubit.openConversation(api.storedConversation); final regeneration = cubit.regenerateLastAnswer(); await Future.delayed(const Duration(milliseconds: 20)); await cubit.cancelGeneration(); await regeneration; expect(cubit.state.messages.last.text, 'إجابة محفوظة'); expect(api.lastSaved?.messages.last.text, 'إجابة محفوظة'); expect(cubit.state.requestStatus, RequestStatus.cancelled); await cubit.close(); await api.controller.close(); }, ); }