Add generation status and cancel control

This commit is contained in:
Hamza Ayed
2026-10-01 13:18:49 +03:00
parent 7a042d036c
commit 6b271ad499
7 changed files with 601 additions and 203 deletions
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/network/api_repository.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
@@ -28,10 +29,40 @@ class _VersionTestApi extends ApiRepository {
}
@override
Stream<String> completeStream(
List<ChatMessage> messages, {
String? model,
}) => Stream<String>.fromIterable(responses.removeAt(0));
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();
}
}
void main() {
@@ -42,18 +73,75 @@ void main() {
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.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.answerVersions, [
'الجواب الأول',
'الجواب الثاني',
]);
expect(api.lastSaved?.messages.last.selectedVersion, 0);
await cubit.close();
});
test(
'cancelling generation preserves partial text and records duration',
() async {
final api = _CancellableTestApi();
final cubit = ChatCubit(api);
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);
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();
},
);
}
@@ -2,6 +2,7 @@ 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/features/chat/presentation/cubit/chat_cubit.dart';
@@ -22,6 +23,22 @@ class _TestApiRepository extends ApiRepository {
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();
}
}
void main() {
testWidgets('chat page fits a compact Windows window', (tester) async {
tester.view.physicalSize = const Size(800, 600);
@@ -47,4 +64,43 @@ void main() {
expect(find.text('مساعدك المحلي'), findsOneWidget);
expect(tester.takeException(), isNull);
});
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);
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();
});
}