386 lines
12 KiB
Dart
386 lines
12 KiB
Dart
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 = <List<String>>[
|
|
['الجواب الأول'],
|
|
['الجواب الثاني'],
|
|
];
|
|
SavedConversation? lastSaved;
|
|
String? indexedWorkspace;
|
|
List<String> indexedFiles = const [];
|
|
final indexedRequests = <List<String>>[];
|
|
final availableFiles = <String>['README.md'];
|
|
List<String> deletedFiles = const [];
|
|
String? registeredProject;
|
|
String? unregisteredProject;
|
|
|
|
@override
|
|
Future<String> getModelName() async => 'gemma4:e2b';
|
|
|
|
@override
|
|
Future<List<ModelDescriptor>> 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<List<AgentSkillDescriptor>> getAgentSkills() async => const [
|
|
AgentSkillDescriptor(
|
|
id: 'code_explain',
|
|
name: 'شرح الكود',
|
|
description: 'شرح المصدر.',
|
|
),
|
|
AgentSkillDescriptor(
|
|
id: 'code_review',
|
|
name: 'مراجعة الكود',
|
|
description: 'مراجعة المصدر.',
|
|
),
|
|
AgentSkillDescriptor(
|
|
id: 'test_plan',
|
|
name: 'خطة اختبارات',
|
|
description: 'اقتراح الاختبارات.',
|
|
),
|
|
];
|
|
|
|
@override
|
|
Future<List<SavedConversation>> listConversations() async => [];
|
|
|
|
@override
|
|
Future<List<String>> listWorkspaceFiles(String workspacePath) async =>
|
|
List.of(availableFiles);
|
|
|
|
@override
|
|
Future<List<String>> registerWorkspaceProject(String workspacePath) async {
|
|
registeredProject = workspacePath;
|
|
return listWorkspaceFiles(workspacePath);
|
|
}
|
|
|
|
@override
|
|
Future<void> unregisterWorkspaceProject(String workspacePath) async {
|
|
unregisteredProject = workspacePath;
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> indexWorkspaceKnowledge(
|
|
String workspacePath,
|
|
List<String> files,
|
|
) async {
|
|
indexedWorkspace = workspacePath;
|
|
indexedFiles = files;
|
|
indexedRequests.add(List.of(files));
|
|
await Future<void>.delayed(const Duration(milliseconds: 1));
|
|
return {
|
|
'indexed': [
|
|
for (final file in files) {'path': file, 'chunks': 2},
|
|
],
|
|
};
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> deleteWorkspaceKnowledge(
|
|
String workspacePath,
|
|
List<String> files,
|
|
) async {
|
|
deletedFiles = files;
|
|
return {
|
|
'deleted': [
|
|
for (final file in files) {'path': file, 'deleted': true},
|
|
],
|
|
};
|
|
}
|
|
|
|
@override
|
|
Future<void> saveConversation(SavedConversation conversation) async {
|
|
lastSaved = conversation;
|
|
}
|
|
|
|
@override
|
|
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
|
|
Stream<String>.fromIterable(responses.removeAt(0));
|
|
}
|
|
|
|
class _CancellableTestApi extends _VersionTestApi {
|
|
final controller = StreamController<String>();
|
|
var cancelled = false;
|
|
final storedConversation = SavedConversation(
|
|
id: 'conversation',
|
|
title: 'سؤال قديم',
|
|
updatedAt: DateTime.utc(2026),
|
|
messages: const [
|
|
ChatMessage('سؤال قديم', fromUser: true),
|
|
ChatMessage(
|
|
'إجابة محفوظة',
|
|
fromUser: false,
|
|
answerVersions: ['إجابة محفوظة'],
|
|
),
|
|
],
|
|
);
|
|
|
|
@override
|
|
Future<SavedConversation> getConversation(String id) async =>
|
|
storedConversation;
|
|
|
|
@override
|
|
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
|
|
controller.stream;
|
|
|
|
@override
|
|
void cancelActiveRequest() {
|
|
cancelled = true;
|
|
controller.close();
|
|
}
|
|
}
|
|
|
|
class _MemorySettingsStore extends LocalSettingsStore {
|
|
final values = <String, Object?>{};
|
|
|
|
@override
|
|
bool get persistenceAvailable => true;
|
|
|
|
@override
|
|
Future<Map<String, Object?>> read() async => Map.of(values);
|
|
|
|
@override
|
|
Future<bool> update(Map<String, Object?> 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<void>.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();
|
|
api.availableFiles.add('lib/main.dart');
|
|
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
await cubit.selectWorkspace(r'C:\Projects\sample');
|
|
final progressUpdates = <String?>[];
|
|
final subscription = cubit.stream.listen(
|
|
(state) => progressUpdates.add(state.agentProgress),
|
|
);
|
|
cubit.selectWorkspaceFiles(['README.md', 'lib/main.dart']);
|
|
final message = await cubit.indexSelectedWorkspaceKnowledge();
|
|
|
|
expect(api.indexedWorkspace, r'C:\Projects\sample');
|
|
expect(api.indexedRequests, [
|
|
['README.md'],
|
|
['lib/main.dart'],
|
|
]);
|
|
expect(message, contains('فُهرست 2 ملفًا'));
|
|
expect(message, contains('4 مقطعًا'));
|
|
expect(
|
|
progressUpdates,
|
|
contains('يفهرس الملف 1/2: README.md…'),
|
|
);
|
|
expect(
|
|
progressUpdates,
|
|
contains('يفهرس الملف 2/2: main.dart…'),
|
|
);
|
|
expect(cubit.state.agentProgress, isNull);
|
|
await subscription.cancel();
|
|
|
|
final removal = await cubit.removeSelectedWorkspaceKnowledge();
|
|
expect(api.deletedFiles, ['README.md', 'lib/main.dart']);
|
|
expect(removal, contains('أُزيل 2 ملف'));
|
|
await cubit.close();
|
|
});
|
|
|
|
test(
|
|
'registered project folders persist and can be removed from the list',
|
|
() async {
|
|
final settings = _MemorySettingsStore();
|
|
final firstApi = _VersionTestApi();
|
|
final first = ChatCubit(firstApi, settingsStore: settings);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
await first.selectWorkspace(r'C:\Projects\sovereign-ai');
|
|
expect(firstApi.registeredProject, r'C:\Projects\sovereign-ai');
|
|
expect(first.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']);
|
|
expect(settings.values['saved_workspace_paths'], [
|
|
r'C:\Projects\sovereign-ai',
|
|
]);
|
|
await first.close();
|
|
|
|
final restoredApi = _VersionTestApi();
|
|
final restored = ChatCubit(restoredApi, settingsStore: settings);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(restored.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']);
|
|
|
|
await restored.removeSavedWorkspace(r'C:\Projects\sovereign-ai');
|
|
expect(restoredApi.unregisteredProject, r'C:\Projects\sovereign-ai');
|
|
expect(restored.state.savedWorkspacePaths, isEmpty);
|
|
expect(settings.values['saved_workspace_paths'], isEmpty);
|
|
await restored.close();
|
|
},
|
|
);
|
|
|
|
test('removing the active project revokes its chat context', () async {
|
|
final api = _VersionTestApi();
|
|
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
|
await Future<void>.delayed(Duration.zero);
|
|
const project = r'C:\Projects\active-project';
|
|
|
|
await cubit.selectWorkspace(project);
|
|
expect(cubit.state.isWorkspaceMode, isTrue);
|
|
await cubit.removeSavedWorkspace(project);
|
|
|
|
expect(api.unregisteredProject, project);
|
|
expect(cubit.state.isWorkspaceMode, isFalse);
|
|
expect(cubit.state.selectedWorkspacePath, isNull);
|
|
expect(cubit.state.availableWorkspaceFiles, isEmpty);
|
|
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<void>.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<void>.delayed(Duration.zero);
|
|
await Future<void>.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<void>.delayed(Duration.zero);
|
|
await first.close();
|
|
|
|
final restoredApi = _VersionTestApi();
|
|
final restored = ChatCubit(restoredApi, settingsStore: settings);
|
|
await Future<void>.delayed(Duration.zero);
|
|
await Future<void>.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<void>.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<void>.delayed(Duration.zero);
|
|
|
|
final send = cubit.send('اكتب فقرة قصيرة');
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
api.controller.add('بداية الإجابة');
|
|
await Future<void>.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<void>.delayed(Duration.zero);
|
|
await cubit.openConversation(api.storedConversation);
|
|
|
|
final regeneration = cubit.regenerateLastAnswer();
|
|
await Future<void>.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();
|
|
},
|
|
);
|
|
}
|