432 lines
14 KiB
Dart
432 lines
14 KiB
Dart
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<String> getModelName() async => 'gemma4:e2b';
|
|
|
|
@override
|
|
Future<List<ModelDescriptor>> getAvailableModels() async => const [
|
|
ModelDescriptor(
|
|
id: 'gemma4:e2b',
|
|
capabilities: ['completion', 'vision', 'audio', 'tools'],
|
|
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<String>> listWorkspaceFiles(String workspacePath) async => [
|
|
'lib/main.dart',
|
|
];
|
|
|
|
@override
|
|
Future<List<String>> registerWorkspaceProject(String workspacePath) async =>
|
|
listWorkspaceFiles(workspacePath);
|
|
|
|
@override
|
|
Future<void> unregisterWorkspaceProject(String workspacePath) async {}
|
|
|
|
@override
|
|
Future<List<SavedConversation>> listConversations() async => [];
|
|
}
|
|
|
|
class _CancellableWidgetApi extends _TestApiRepository {
|
|
final controller = StreamController<String>();
|
|
|
|
@override
|
|
Future<void> saveConversation(SavedConversation conversation) async {}
|
|
|
|
@override
|
|
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
|
|
controller.stream;
|
|
|
|
@override
|
|
void cancelActiveRequest() {
|
|
controller.close();
|
|
}
|
|
}
|
|
|
|
class _MarkdownWidgetApi extends _TestApiRepository {
|
|
@override
|
|
Future<void> saveConversation(SavedConversation conversation) async {}
|
|
|
|
@override
|
|
Stream<String> completeStream(List<ChatMessage> 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<void> saveConversation(SavedConversation conversation) async {}
|
|
|
|
@override
|
|
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
|
|
'src/hello.py',
|
|
];
|
|
|
|
@override
|
|
Future<List<String>> registerWorkspaceProject(String workspacePath) async =>
|
|
listWorkspaceFiles(workspacePath);
|
|
|
|
@override
|
|
Future<void> unregisterWorkspaceProject(String workspacePath) async {}
|
|
|
|
@override
|
|
Future<AgentRunResult> runAgent(
|
|
String task, {
|
|
String? model,
|
|
String? workspacePath,
|
|
List<String> 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<String> 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<ApiRepository>.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(
|
|
find.byTooltip('تسجيل صوت؛ يُرسل إلى Groq السحابي لتحويله إلى نص'),
|
|
findsOneWidget,
|
|
);
|
|
expect(tester.takeException(), isNull);
|
|
|
|
await chatCubit.setDarkMode(true);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.widget<MaterialApp>(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<ApiRepository>.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('wide desktop shows a labeled project registration action', (
|
|
tester,
|
|
) async {
|
|
tester.view.physicalSize = const Size(1500, 900);
|
|
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<ApiRepository>.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(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<ApiRepository>.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<String>));
|
|
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<ApiRepository>.value(value: api)],
|
|
child: MultiBlocProvider(
|
|
providers: [
|
|
BlocProvider<ChatCubit>.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<ApiRepository>.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<ApiRepository>.value(value: api)],
|
|
child: MultiBlocProvider(
|
|
providers: [
|
|
BlocProvider<ChatCubit>.value(value: cubit),
|
|
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
|
],
|
|
child: const SovereignApp(),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
Future<void> 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();
|
|
});
|
|
}
|