feat(chat): add consultation progress and personal diet status

related to #1115
This commit is contained in:
2026-11-03 14:45:44 +03:00
parent 53ce73b41e
commit bd1155233f
35 changed files with 1999 additions and 265 deletions

View File

@@ -78,6 +78,66 @@ void main() {
meta: const ChatMetaModel(lastPage: 1),
);
const planForChosenPlanUpdates = PlanModel(
id: 1,
slug: 'test-plan',
name: 'Test Plan',
image: 'https://example.com/image.jpg',
duration: 7,
state: 'active',
type: PlanType(
fit: true,
slim: false,
behavior: false,
wellness: false,
),
details: 'Test details',
packetsCount: 3,
author: Author(
authorSlug: 'author',
authorName: 'Author Name',
authorProfileIcon: 'https://example.com/icon.jpg',
authorIsMe: false,
),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
isFavorite: false,
minPriceOfOptions: 1000,
maxPriceOfOptions: 2000,
wasLikedByMe: false,
);
ChosenPlanModel buildChosenPlan(
int id,
ChosenPlanStateEnum chosenPlanState,
) {
return ChosenPlanModel(
id: id,
optionType: 'options1',
startDate: DateTime.now(),
chosenPlanState: chosenPlanState,
chatId: 'chat-id',
consultantId: 1,
plan: planForChosenPlanUpdates,
);
}
void stubChatChannel({
required Stream<ChannelReadEvent> chosenPlanStream,
}) {
when(mockWsManager.client).thenReturn(MockPusherChannelsClient());
when(
mockWsManager.directChatStream,
).thenAnswer((_) => const Stream<ChannelReadEvent>.empty());
when(
mockWsManager.clientTypingStream,
).thenAnswer((_) => const Stream<ChannelReadEvent>.empty());
when(
mockWsManager.chosenPlanStateChangedStream,
).thenAnswer((_) => chosenPlanStream);
}
setUp(() {
mockChatRepository = MockChatRepository();
mockCardsRepository = MockCardsRepository();
@@ -721,6 +781,160 @@ void main() {
},
);
test(
'chosenPlanStateChangedStream - перезапрашивает план и обновляет state',
() async {
const uuid = 'room-uuid';
const planId = 321;
final chosenPlan = buildChosenPlan(
planId,
ChosenPlanStateEnum.stateStarted,
);
final updatedPlan = chosenPlan.copyWith(
chosenPlanState: ChosenPlanStateEnum.statePaid,
);
final controller = StreamController<ChannelReadEvent>.broadcast();
when(
mockChatRepository.getChatRoom(uuid),
).thenAnswer((_) async => testChatRoom.copyWith(chosenPlanId: planId));
when(
mockChatRepository.getChatMessages(uuid, 1),
).thenAnswer((_) async => testChatMessages);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => chosenPlan);
stubChatChannel(chosenPlanStream: controller.stream);
await chatRoomCubit.initChat(uuid);
expect(chatRoomCubit.state, isA<ChatRoomStateLoaded>());
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller.add(_FakeChannelReadEvent());
await Future<void>.delayed(const Duration(milliseconds: 350));
verify(mockChatRepository.getChosenPlan(id: planId)).called(1);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(
state.chatRoom.chosenPlan?.chosenPlanState,
ChosenPlanStateEnum.statePaid,
);
await controller.close();
},
);
test(
'chosenPlanStateChangedStream - использует chosenPlan.id если chosenPlanId null',
() async {
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872';
const planId = 654;
final chosenPlan = buildChosenPlan(
planId,
ChosenPlanStateEnum.stateStarted,
);
final updatedPlan = chosenPlan.copyWith(
chosenPlanState: ChosenPlanStateEnum.statePaid,
);
final controller = StreamController<ChannelReadEvent>.broadcast();
when(
mockChatRepository.resolveOrderIdToConversation(orderId: orderId),
).thenAnswer(
(_) async => OrderIdToConversationModel(
chosenPlanId: planId,
conversationUuid: conversationUuid,
),
);
when(
mockChatRepository.getChatRoom(conversationUuid),
).thenAnswer((_) async => testChatRoom);
when(
mockChatRepository.getChatMessages(conversationUuid, 1),
).thenAnswer((_) async => testChatMessages);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => chosenPlan);
stubChatChannel(chosenPlanStream: controller.stream);
await chatRoomCubit.initChat(null, orderId: orderId);
final loaded = chatRoomCubit.state as ChatRoomStateLoaded;
expect(loaded.chatRoom.chosenPlanId, isNull);
expect(loaded.chatRoom.chosenPlan?.id, planId);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller.add(_FakeChannelReadEvent());
await Future<void>.delayed(const Duration(milliseconds: 350));
verify(mockChatRepository.getChosenPlan(id: planId)).called(1);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(
state.chatRoom.chosenPlan?.chosenPlanState,
ChosenPlanStateEnum.statePaid,
);
await controller.close();
},
);
test(
'chosenPlanStateChangedStream - дебаунсит повторные события',
() async {
const uuid = 'room-uuid';
const planId = 987;
final chosenPlan = buildChosenPlan(
planId,
ChosenPlanStateEnum.stateStarted,
);
final updatedPlan = chosenPlan.copyWith(
chosenPlanState: ChosenPlanStateEnum.statePaid,
);
final controller = StreamController<ChannelReadEvent>.broadcast();
when(
mockChatRepository.getChatRoom(uuid),
).thenAnswer((_) async => testChatRoom.copyWith(chosenPlanId: planId));
when(
mockChatRepository.getChatMessages(uuid, 1),
).thenAnswer((_) async => testChatMessages);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => chosenPlan);
stubChatChannel(chosenPlanStream: controller.stream);
await chatRoomCubit.initChat(uuid);
expect(chatRoomCubit.state, isA<ChatRoomStateLoaded>());
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller
..add(_FakeChannelReadEvent())
..add(_FakeChannelReadEvent());
await Future<void>.delayed(const Duration(milliseconds: 350));
verify(mockChatRepository.getChosenPlan(id: planId)).called(1);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(
state.chatRoom.chosenPlan?.chosenPlanState,
ChosenPlanStateEnum.statePaid,
);
await controller.close();
},
);
test('choseStartPlanDate - устанавливает дату начала плана', () async {
const uuid = 'room-uuid';
const planId = 123;
@@ -2578,3 +2792,8 @@ void main() {
);
});
}
class _FakeChannelReadEvent extends Fake implements ChannelReadEvent {
@override
dynamic get data => null;
}

View File

@@ -0,0 +1,232 @@
import 'package:eatvio_app/features/chat/presentation/utils/chat_banner_resolver.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:flutter_test/flutter_test.dart';
ConsultationProgress _progress(
ChosenPlanStateEnum state, {
bool hasPersonalDiet = false,
}) {
return mapConsultationProgress(
state: state,
hasPersonalDiet: hasPersonalDiet,
);
}
void main() {
group('resolveChatBanner — карта консультанта', () {
test('in_coordination без карты просит добавить карту', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateInCoordination),
amIConsultant: true,
hasCard: false,
state: ChosenPlanStateEnum.stateInCoordination,
);
expect(banner, BannerType.card);
});
test('in_coordination с картой просит выбрать дату', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateInCoordination),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateInCoordination,
);
expect(banner, BannerType.date);
});
test('paid + notProposed + диета просит предложить диету', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.statePaid,
hasPersonalDiet: true,
),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.statePaid,
);
expect(banner, BannerType.setPersonalDiet);
});
test(
'personal_diet_setted/started/in_progress + proposed просят сменить диету',
() {
const states = [
ChosenPlanStateEnum.personalDietSet,
ChosenPlanStateEnum.stateStarted,
ChosenPlanStateEnum.stateInProgress,
];
for (final state in states) {
final banner = resolveChatBanner(
progress: _progress(state, hasPersonalDiet: true),
amIConsultant: true,
hasCard: true,
state: state,
);
expect(
banner,
BannerType.changePersonalDiet,
reason: 'state=${state.name}',
);
}
},
);
test('last_day_finished не просит менять диету (нет баннера)', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.stateLastDayFinished,
hasPersonalDiet: true,
),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateLastDayFinished,
);
expect(banner, isNot(BannerType.changePersonalDiet));
expect(banner, isNull);
});
test('done не просит менять диету (collectCharge)', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.stateDone,
hasPersonalDiet: true,
),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateDone,
);
expect(banner, isNot(BannerType.changePersonalDiet));
expect(banner, BannerType.collectCharge);
});
});
group('resolveChatBanner — карта клиента', () {
test('start_date_has_been_set просит оплатить', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateStartDateHasBeenSet),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.stateStartDateHasBeenSet,
);
expect(banner, BannerType.payment);
});
test('pending_payment просит оплатить', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePendingPayment),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.statePendingPayment,
);
expect(banner, BannerType.payment);
});
test('paid + notProposed не показывает diet-баннер клиенту', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.statePaid,
hasPersonalDiet: true,
),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.statePaid,
);
expect(banner, isNull);
});
test(
'personal_diet_setted + proposed не показывает diet-баннер клиенту',
() {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.personalDietSet,
);
expect(banner, isNull);
},
);
});
group('resolveChatBanner — терминальные фазы', () {
test('canceled не показывает баннер', () {
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateCanceledByTheInitiator),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateCanceledByTheInitiator,
),
isNull,
);
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateCanceledByTheInitiator),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.stateCanceledByTheInitiator,
),
isNull,
);
});
test('payment_failed не показывает баннер', () {
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePaymentFailed),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.statePaymentFailed,
),
isNull,
);
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePaymentFailed),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.statePaymentFailed,
),
isNull,
);
});
test('нет progress или state — нет баннера', () {
expect(
resolveChatBanner(
progress: null,
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.statePaid,
),
isNull,
);
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePaid),
amIConsultant: true,
hasCard: true,
state: null,
),
isNull,
);
});
});
}

View File

@@ -0,0 +1,82 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_consultation_header.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: Center(child: child)),
);
}
void main() {
group('ChatConsultationHeader', () {
testWidgets('вызывает onInfoTap по тапу на info', (tester) async {
var tapped = 0;
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateInited,
hasPersonalDiet: false,
);
await tester.pumpWidget(
_wrap(
ChatConsultationHeader(
progress: progress,
showInfoButton: true,
onInfoTap: () => tapped++,
),
),
);
await tester.tap(find.byIcon(Icons.info_outline_rounded));
await tester.pump();
expect(tapped, 1);
});
testWidgets(
'при showProgressPlank=false и proposed видны заголовок и чип, плашки нет',
(tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isFalse);
expect(progress.dietStatus, PersonalDietStatus.proposed);
await tester.pumpWidget(
_wrap(
ChatConsultationHeader(
progress: progress,
showInfoButton: false,
onInfoTap: () {},
),
),
);
final s = S.of(tester.element(find.byType(ChatConsultationHeader)));
expect(find.text(s.more_about_order), findsOneWidget);
expect(find.byType(PersonalDietStatusChip), findsOneWidget);
expect(find.text(s.personal_diet_proposed), findsOneWidget);
expect(find.byType(ConsultationProgressPlank), findsNothing);
expect(find.byIcon(Icons.check), findsNothing);
},
);
});
}