Merge remote-tracking branch 'upstream/main' into issue-1123

This commit is contained in:
misha
2026-11-11 10:49:33 +03:00
59 changed files with 3234 additions and 494 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,86 @@
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(
'personal_diet_setted показывает плашку и чип вместе с баннером замены',
(tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
personalDietName: 'Индивидуальный план питания',
);
expect(progress.showProgressPlank, isTrue);
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.order_in_progress), findsNWidgets(2));
expect(find.byType(PersonalDietStatusChip), findsOneWidget);
expect(
find.text(s.personal_diet_proposed_named('Индивидуальный план питания')),
findsOneWidget,
);
expect(find.byType(ConsultationProgressPlank), findsOneWidget);
expect(find.byIcon(Icons.check), findsNWidgets(2));
},
);
});
}

View File

@@ -174,11 +174,13 @@ void main() {
'consultant_id': 1,
'diaries': [],
'personal_diet_id': null,
'personal_diet_name': 'Индивидуальный план питания',
};
final model = ConsultationModel.fromJson(json);
expect(model.chatId, 'direct-chat-uuid');
expect(model.personalDietName, 'Индивидуальный план питания');
});
test('fromJson allows missing chat_id', () {

View File

@@ -153,6 +153,29 @@ void main() {
});
});
testWidgets('shows personal diet name in status chip', (tester) async {
final consultation = mockConsultation.copyWith(
personalDietId: 1,
personalDietName: 'Индивидуальный план питания',
);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(ConsultationCardWidget(consultationsModel: consultation)),
);
await tester.pumpAndSettle();
final s = S.of(tester.element(find.byType(ConsultationCardWidget)));
expect(
find.text(
s.personal_diet_proposed_named('Индивидуальный план питания'),
),
findsOneWidget,
);
});
});
testWidgets('renders diary days', (tester) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(

View File

@@ -0,0 +1,99 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.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('ConsultationProgressPlank', () {
testWidgets('не строит шаги при showProgressPlank=false', (tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateConfirmedByConsultant,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isFalse);
await tester.pumpWidget(
_wrap(ConsultationProgressPlank(progress: progress)),
);
expect(
find.descendant(
of: find.byType(ConsultationProgressPlank),
matching: find.byType(SizedBox),
),
findsOneWidget,
);
expect(find.byIcon(Icons.check), findsNothing);
expect(find.byType(Text), findsNothing);
});
testWidgets(
'рисует 4 шага с иконками и подписями при showProgressPlank=true',
(tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateDone,
hasPersonalDiet: false,
);
expect(progress.showProgressPlank, isTrue);
await tester.pumpWidget(
_wrap(ConsultationProgressPlank(progress: progress)),
);
final s = S.of(tester.element(find.byType(ConsultationProgressPlank)));
expect(find.byIcon(Icons.check), findsNWidgets(4));
expect(find.text(s.startDateHasBeenSet), findsOneWidget);
expect(find.text(s.order_paid), findsOneWidget);
expect(find.text(s.order_in_progress), findsOneWidget);
expect(find.text(s.done), findsOneWidget);
},
);
testWidgets('подписи шагов центрированы под своими кружками', (
tester,
) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateDone,
hasPersonalDiet: false,
);
await tester.pumpWidget(
_wrap(
SizedBox(
width: 360,
child: ConsultationProgressPlank(progress: progress),
),
),
);
for (var index = 0; index < 4; index++) {
final circle = tester.getCenter(
find.byKey(ValueKey('consultation_step_circle_$index')),
);
final label = tester.getCenter(
find.byKey(ValueKey('consultation_step_label_$index')),
);
expect(label.dx, closeTo(circle.dx, 0.5));
}
});
});
}

View File

@@ -0,0 +1,111 @@
import 'package:eatvio_app/core/theme/theme_helper.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)),
);
}
Widget _chip(
ChosenPlanStateEnum state, {
bool hasPersonalDiet = true,
String? personalDietName,
}) {
return PersonalDietStatusChip(
progress: mapConsultationProgress(
state: state,
hasPersonalDiet: hasPersonalDiet,
personalDietName: personalDietName,
),
);
}
void main() {
group('PersonalDietStatusChip', () {
testWidgets('notRequired + active ничего не показывает', (tester) async {
await tester.pumpWidget(
_wrap(_chip(ChosenPlanStateEnum.stateInited, hasPersonalDiet: false)),
);
expect(
find.descendant(
of: find.byType(PersonalDietStatusChip),
matching: find.byType(SizedBox),
),
findsOneWidget,
);
expect(find.byType(Text), findsNothing);
});
testWidgets('proposed показывает personal_diet_proposed', (tester) async {
await tester.pumpWidget(_wrap(_chip(ChosenPlanStateEnum.stateStarted)));
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.personal_diet_proposed), findsOneWidget);
});
testWidgets('proposed с названием показывает имя диеты', (tester) async {
await tester.pumpWidget(
_wrap(
_chip(
ChosenPlanStateEnum.stateStarted,
personalDietName: 'Индивидуальный план питания',
),
),
);
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(
find.text(s.personal_diet_proposed_named('Индивидуальный план питания')),
findsOneWidget,
);
});
testWidgets('notProposed показывает personal_diet_not_proposed', (
tester,
) async {
await tester.pumpWidget(_wrap(_chip(ChosenPlanStateEnum.statePaid)));
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.personal_diet_not_proposed), findsOneWidget);
});
testWidgets('canceled показывает orderCanceled', (tester) async {
await tester.pumpWidget(
_wrap(_chip(ChosenPlanStateEnum.stateCanceledByTheInitiator)),
);
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.orderCanceled), findsOneWidget);
});
testWidgets('paymentFailed показывает payment_failed', (tester) async {
await tester.pumpWidget(
_wrap(_chip(ChosenPlanStateEnum.statePaymentFailed)),
);
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.payment_failed), findsOneWidget);
});
});
}

View File

@@ -0,0 +1,289 @@
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';
typedef _Expected = ({
Set<ConsultationStep> steps,
bool plank,
PersonalDietStatus diet,
ConsultationPhase phase,
});
const _startDate = ConsultationStep.startDate;
const _paid = ConsultationStep.paid;
const _inProgress = ConsultationStep.inProgress;
const _finished = ConsultationStep.finished;
final Map<ChosenPlanStateEnum, _Expected> _expectations = {
ChosenPlanStateEnum.stateInited: (
steps: {},
plank: false,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateStartDateHasBeenSet: (
steps: {_startDate},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateInCoordination: (
steps: {},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateStarted: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateInProgress: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.statePendingPayment: (
steps: {_startDate},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateConfirmedByConsultant: (
steps: {},
plank: false,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.statePaid: (
steps: {_startDate, _paid},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateCanceledByTheInitiator: (
steps: {},
plank: false,
diet: PersonalDietStatus.notRequired,
phase: ConsultationPhase.canceled,
),
ChosenPlanStateEnum.stateLastDayStarted: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateLastDayFinished: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateComplained: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateDone: (
steps: {_startDate, _paid, _inProgress, _finished},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.personalDietSet: (
steps: {_startDate, _paid},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateChargeWasCollected: (
steps: {_startDate, _paid, _inProgress, _finished},
plank: false,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.statePaymentFailed: (
steps: {_startDate},
plank: false,
diet: PersonalDietStatus.notRequired,
phase: ConsultationPhase.paymentFailed,
),
};
void main() {
group('mapConsultationProgress — все состояния', () {
test('таблица покрывает ровно все 16 состояний', () {
expect(_expectations.length, 16);
expect(_expectations.keys.toSet(), ChosenPlanStateEnum.values.toSet());
});
for (final entry in _expectations.entries) {
test('${entry.key.name} маппится во все поля ConsultationProgress', () {
final progress = mapConsultationProgress(
state: entry.key,
hasPersonalDiet: true,
);
expect(progress.completedSteps, unorderedEquals(entry.value.steps));
expect(progress.showProgressPlank, entry.value.plank);
expect(progress.dietStatus, entry.value.diet);
expect(progress.phase, entry.value.phase);
});
}
});
group('mapConsultationProgress — ключевые сценарии', () {
test('in_coordination показывает плашку без завершённых шагов', () {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateInCoordination,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isTrue);
expect(progress.completedSteps, isEmpty);
});
test(
'personal_diet_setted показывает плашку и завершает startDate+paid',
() {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isTrue);
expect(progress.completedSteps, unorderedEquals([_startDate, _paid]));
},
);
test(
'started/in_progress/last_day_started/last_day_finished/complained — 3 шага',
() {
const states = [
ChosenPlanStateEnum.stateStarted,
ChosenPlanStateEnum.stateInProgress,
ChosenPlanStateEnum.stateLastDayStarted,
ChosenPlanStateEnum.stateLastDayFinished,
ChosenPlanStateEnum.stateComplained,
];
for (final state in states) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: true,
);
expect(
progress.completedSteps,
unorderedEquals([_startDate, _paid, _inProgress]),
reason: 'state=${state.name}',
);
}
},
);
test('done/charge_was_collected — 4 шага', () {
const states = [
ChosenPlanStateEnum.stateDone,
ChosenPlanStateEnum.stateChargeWasCollected,
];
for (final state in states) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: true,
);
expect(
progress.completedSteps,
unorderedEquals([_startDate, _paid, _inProgress, _finished]),
reason: 'state=${state.name}',
);
}
});
test(
'canceled — фаза canceled и dietStatus notRequired даже при диете',
() {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateCanceledByTheInitiator,
hasPersonalDiet: true,
);
expect(progress.phase, ConsultationPhase.canceled);
expect(progress.isCanceled, isTrue);
expect(progress.dietStatus, PersonalDietStatus.notRequired);
},
);
test('payment_failed — фаза paymentFailed и только startDate', () {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.statePaymentFailed,
hasPersonalDiet: true,
);
expect(progress.phase, ConsultationPhase.paymentFailed);
expect(progress.isPaymentFailed, isTrue);
expect(progress.completedSteps, unorderedEquals([_startDate]));
});
test('hasPersonalDiet=false даёт notRequired для любого состояния', () {
for (final state in ChosenPlanStateEnum.values) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: false,
);
expect(
progress.dietStatus,
PersonalDietStatus.notRequired,
reason: 'state=${state.name}',
);
expect(progress.hasDietChip, isFalse, reason: 'state=${state.name}');
}
});
test('paid с диетой даёт notProposed', () {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.statePaid,
hasPersonalDiet: true,
);
expect(progress.dietStatus, PersonalDietStatus.notProposed);
});
test('personal_diet_setted/started/proposed дают proposed', () {
const states = [
ChosenPlanStateEnum.personalDietSet,
ChosenPlanStateEnum.stateStarted,
];
for (final state in states) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: true,
);
expect(
progress.dietStatus,
PersonalDietStatus.proposed,
reason: 'state=${state.name}',
);
}
});
test('personalDietName передаётся в ConsultationProgress', () {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
personalDietName: 'Индивидуальный план питания',
);
expect(progress.personalDietName, 'Индивидуальный план питания');
});
});
}

View File

@@ -27,9 +27,7 @@ void main() {
await tester.pumpWidget(
createTestWidget(
const ChosenDietWidgetContent(
currentDietDay: 5,
dietName: 'Test Diet',
dietDuration: 14,
dietId: 1,
),
),
@@ -38,15 +36,14 @@ void main() {
expect(find.byType(ChosenDietWidgetContent), findsOneWidget);
expect(find.text('Test Diet'), findsOneWidget);
expect(find.textContaining('из 14'), findsNothing);
});
testWidgets('displays current day and duration', (tester) async {
testWidgets('does not display current day and duration', (tester) async {
await tester.pumpWidget(
createTestWidget(
const ChosenDietWidgetContent(
currentDietDay: 7,
dietName: 'Test Diet',
dietDuration: 21,
dietId: 1,
),
),
@@ -54,15 +51,14 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(ChosenDietWidgetContent), findsOneWidget);
expect(find.textContaining('из 21'), findsNothing);
});
testWidgets('displays diet name with underline', (tester) async {
await tester.pumpWidget(
createTestWidget(
const ChosenDietWidgetContent(
currentDietDay: 1,
dietName: 'My Diet',
dietDuration: 7,
dietId: 1,
),
),
@@ -70,6 +66,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('My Diet'), findsOneWidget);
expect(find.textContaining('из 7'), findsNothing);
});
});
}

View File

@@ -100,6 +100,17 @@ void main() {
await tester.pumpWidget(wrap(const ChosenDietWidget(), cubit));
await tester.pump();
expect(find.text('Diet'), findsOneWidget);
expect(find.textContaining('из 7'), findsNothing);
});
testWidgets('ChosenDietWidget hides diet day while plan keeps its day', (tester) async {
final cubit = FakeDiaryCubit(loadedState(hasDiet: true, hasPlan: true));
await tester.pumpWidget(wrap(const Column(children: [ChosenPlanWidget(), ChosenDietWidget()]), cubit));
await tester.pump();
expect(find.text('Plan'), findsOneWidget);
expect(find.textContaining('из 30'), findsOneWidget);
expect(find.text('Diet'), findsOneWidget);
expect(find.textContaining('из 7'), findsNothing);
});
testWidgets('AddProductButtonWidget renders for loaded state', (tester) async {

View File

@@ -92,6 +92,7 @@ void main() {
DiaryReminderForUsersNotification.fromJson(base, NotificationType.diaryReminderForUsers);
DiaryReminderForUsersNotification.fromJson({'created_at': c, 'data': <String, dynamic>{}}, NotificationType.diaryReminderForUsers);
PersonalDietIsDefinedToCustomerNotification.fromJson(base, NotificationType.personalDietIsDefinedToCustomer);
PersonalDietIsDefinedToAuthorNotification.fromJson(base, NotificationType.personalDietIsDefinedToAuthor);
ConsultantEducationUpdatedNotification.fromJson(base, NotificationType.consultantEducationUpdated);
ComplaintRegisteredNotification.fromJson(base, NotificationType.complaintRegistered);
ContentUnpublishedNotification.fromJson(base, NotificationType.contentUnpublished);

View File

@@ -50,6 +50,7 @@ void main() {
'stay-on-track-to-customer': NotificationType.stayOnTrackToCustomer,
'diary-reminder-for-users': NotificationType.diaryReminderForUsers,
'personal-diet-is-defined-to-customer': NotificationType.personalDietIsDefinedToCustomer,
'personal-diet-is-defined-to-author': NotificationType.personalDietIsDefinedToAuthor,
};
cases.forEach((key, expected) {
@@ -671,6 +672,34 @@ void main() {
expect(notification.type, NotificationType.personalizedDietIsDefined);
});
test('PersonalDietIsDefinedToAuthorNotification.fromJson parses all fields', () {
final json = {
'chosen_plan_id': 12,
'diet_id': 34,
'plan_name': 'Plan',
'user_name': 'Client',
'consultant_name': 'Consultant',
'diet_name': 'Personal diet',
'role': 'consultant',
'peer_slug': 'client-slug',
'peer_image_path': 'avatars/client.png',
'created_at': '2024-01-30T12:00:00Z',
};
final notification = PersonalDietIsDefinedToAuthorNotification.fromJson(
json,
NotificationType.personalDietIsDefinedToAuthor,
isRead: false,
);
expect(notification.chosenPlanId, 12);
expect(notification.dietId, 34);
expect(notification.userName, 'Client');
expect(notification.dietName, 'Personal diet');
expect(notification.peerSlug, 'client-slug');
expect(notification.type, NotificationType.personalDietIsDefinedToAuthor);
});
test('ChosenPlanFinishedToClientNotification.fromJson parses all fields', () {
final json = {
'plan_name': 'Finished Plan',

View File

@@ -1212,6 +1212,89 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays PersonalDietIsDefinedToAuthorNotification', (
tester,
) async {
final notification = PersonalDietIsDefinedToAuthorNotification(
type: NotificationType.personalDietIsDefinedToAuthor,
isRead: false,
createdAt: testDate,
chosenPlanId: 1,
dietId: 1,
planName: 'Plan',
userName: 'Client',
consultantName: 'Consultant',
dietName: 'Test Diet',
role: 'consultant',
peerSlug: 'client-slug',
peerImagePath: '',
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(
find.text('Вы предложили персональную диету пользователю Client, выбранная диета: «Test Diet»'),
findsOneWidget,
);
});
testWidgets(
'displays PersonalDietIsDefinedToAuthorNotification with fallback user name',
(tester) async {
final notification = PersonalDietIsDefinedToAuthorNotification(
type: NotificationType.personalDietIsDefinedToAuthor,
isRead: false,
createdAt: testDate,
chosenPlanId: 1,
dietId: 1,
planName: 'Plan',
userName: '',
consultantName: 'Consultant',
dietName: 'Test Diet',
role: 'consultant',
peerSlug: 'client-slug',
peerImagePath: '',
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(
find.text('Вы предложили персональную диету пользователю Пользователь, выбранная диета: «Test Diet»'),
findsOneWidget,
);
},
);
testWidgets(
'displays PersonalDietIsDefinedToAuthorNotification without diet name',
(tester) async {
final notification = PersonalDietIsDefinedToAuthorNotification(
type: NotificationType.personalDietIsDefinedToAuthor,
isRead: false,
createdAt: testDate,
chosenPlanId: 1,
dietId: 1,
planName: 'Plan',
userName: 'Client',
consultantName: 'Consultant',
dietName: '',
role: 'consultant',
peerSlug: 'client-slug',
peerImagePath: '',
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(
find.text('Вы предложили персональную диету пользователю Client'),
findsOneWidget,
);
},
);
testWidgets('displays UnknownNotification', (tester) async {
final notification = UnknownNotification(
type: NotificationType.unknown,

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/notifications/models/notification_model.dart';
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
@@ -1454,6 +1455,42 @@ void main() {
expect(result, isNotNull);
expect(result!['notifications'], isA<List>());
expect(result['notifications'].length, 1);
expect(result['notifications'].first, isA<PersonalDietIsDefinedToCustomerNotification>());
});
test('getNotifications - обрабатывает PersonalDietIsDefinedToAuthor', () async {
const page = 1;
final response = Response(
data: {
'data': [
{
'type': 'personal-diet-is-defined-to-author',
'data': {
'diet_name': 'Test Diet',
'diet_id': 1,
'user_name': 'Client',
},
'read_at': null,
'created_at': '2024-01-01T12:00:00Z',
},
],
'current_page': 1,
'last_page': 1,
'per_page': 10,
'total': 1,
},
statusCode: 200,
requestOptions: RequestOptions(path: '/api/profile/notifications'),
);
when(mockDio.get(any)).thenAnswer((_) async => response);
final result = await notificationsService.getNotifications(page: page);
expect(result, isNotNull);
expect(result!['notifications'], isA<List>());
expect(result['notifications'].length, 1);
expect(result['notifications'].first, isA<PersonalDietIsDefinedToAuthorNotification>());
});
test('getNotifications - обрабатывает UserDailyReportCompleted', () async {

View File

@@ -243,12 +243,12 @@ void main() {
expect(find.byType(InfoByOrderScreen), findsOneWidget);
});
ChosenPlanModel donePlan() {
ChosenPlanModel donePlan({ChosenPlanStateEnum state = ChosenPlanStateEnum.stateDone}) {
return ChosenPlanModel(
id: 1,
optionType: 'options1',
startDate: DateTime(2024, 1, 1),
chosenPlanState: ChosenPlanStateEnum.stateDone,
chosenPlanState: state,
chatId: 'chat-1',
consultantId: 1,
plan: const PlanModel(
@@ -320,6 +320,7 @@ void main() {
expect(find.byType(CollectChargeBanner), findsOneWidget);
expect(find.text('Получить выплату'), findsOneWidget);
expect(find.text('Отменить консультацию'), findsNothing);
},
);
@@ -337,6 +338,22 @@ void main() {
expect(find.byType(CollectChargeBanner), findsNothing);
expect(find.text('Получить выплату'), findsNothing);
expect(find.text('Отменить консультацию'), findsNothing);
});
testWidgets('shows cancel action for cancellable post-paid plan', (
WidgetTester tester,
) async {
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenAnswer((_) async => donePlan(state: ChosenPlanStateEnum.stateStarted));
await mockNetworkImagesFor(() async {
await tester.pumpWidget(createTestWidget(planId: 1));
await tester.pumpAndSettle();
});
expect(find.text('Отменить консультацию'), findsOneWidget);
});
});
}

View File

@@ -34,6 +34,7 @@ void main() {
expect(cubit.state.consultationsWithDiet, isEmpty);
expect(cubit.state.consultationsWithoutDiet, isEmpty);
expect(cubit.state.dietName, isEmpty);
expect(cubit.state.dietDuration, 0);
expect(cubit.state.isLoading, isFalse);
expect(cubit.state.error, isNull);
});
@@ -71,6 +72,7 @@ void main() {
await cubit.setDietName(dietId);
expect(cubit.state.dietName, equals(dietName));
expect(cubit.state.dietDuration, 30);
expect(cubit.state.isLoading, isFalse);
verify(mockDietRepository.getDiet(dietId)).called(1);
});
@@ -161,6 +163,7 @@ void main() {
await cubit.getConsultationWithPersonalDiets(dietId);
expect(cubit.state.dietName, equals(dietName));
expect(cubit.state.dietDuration, 30);
expect(cubit.state.consultationsWithDiet.length, 1);
expect(cubit.state.consultationsWithoutDiet.length, 1);
expect(cubit.state.isLoading, isFalse);
@@ -216,6 +219,8 @@ void main() {
consultationId: consultationId,
dietId: dietId,
)).called(1);
verify(mockPersonalDietSuggestionRepository.getConsultationWithPersonalDiets()).called(2);
verify(mockDietRepository.getDiet(dietId)).called(2);
});
test('reattachDietToConsultation - успешное переприкрепление диеты', () async {
@@ -265,6 +270,8 @@ void main() {
consultationId: consultationId,
dietId: dietId,
)).called(1);
verify(mockPersonalDietSuggestionRepository.getConsultationWithPersonalDiets()).called(2);
verify(mockDietRepository.getDiet(dietId)).called(2);
});
});
}

View File

@@ -9,6 +9,7 @@ void main() {
expect(state.consultationsWithDiet, isEmpty);
expect(state.consultationsWithoutDiet, isEmpty);
expect(state.dietName, '');
expect(state.dietDuration, 0);
expect(state.isLoading, isFalse);
expect(state.error, isNull);
});
@@ -16,6 +17,7 @@ void main() {
test('creates instance with custom values', () {
final state = PersonalDietSuggestionState(
dietName: 'Test Diet',
dietDuration: 14,
isLoading: true,
error: 'Test error',
);
@@ -23,6 +25,7 @@ void main() {
expect(state.consultationsWithDiet, isEmpty);
expect(state.consultationsWithoutDiet, isEmpty);
expect(state.dietName, 'Test Diet');
expect(state.dietDuration, 14);
expect(state.isLoading, isTrue);
expect(state.error, 'Test error');
});
@@ -31,14 +34,15 @@ void main() {
final original = PersonalDietSuggestionState();
final updated = original.copyWith(
dietName: 'New Diet',
dietDuration: 7,
isLoading: true,
);
expect(updated.dietName, 'New Diet');
expect(updated.dietDuration, 7);
expect(updated.isLoading, isTrue);
expect(updated.consultationsWithDiet, original.consultationsWithDiet);
expect(updated.error, original.error);
});
});
}

View File

@@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/localization_utils.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
@@ -34,6 +35,7 @@ void main() {
final mockConsultation = ConsultationModel(
id: 1,
chatId: 'chat-1',
chosenPlanState: ChosenPlanStateEnum.stateInProgress,
planTitle: 'Test Plan',
userName: 'Test User',
@@ -68,6 +70,32 @@ void main() {
path: '/personal-diet-suggestion',
builder: (context, state) => const Scaffold(body: Text('Personal Diet')),
),
GoRoute(
path: '/chat-select-screen',
name: 'chatSelectScreen',
builder: (context, state) => const Scaffold(body: Text('Chats')),
),
GoRoute(
path: '/chat-screen',
name: 'chatScreen',
builder: (context, state) {
final uuid = state.uri.queryParameters['uuid'];
return Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () {
if (context.canPop()) {
GoRouter.of(context).pop();
return;
}
GoRouter.of(context).go('/chat-select-screen');
},
),
),
body: Text('Chat $uuid'),
);
},
),
],
),
);
@@ -201,7 +229,7 @@ void main() {
});
});
testWidgets('shows success bottom sheet after successful diet attachment', (tester) async {
testWidgets('opens chat after successful diet attachment', (tester) async {
await mockNetworkImagesFor(() async {
final cubit = MockPersonalDietSuggestionCubit();
@@ -226,7 +254,99 @@ void main() {
await tester.tap(offerButton);
await tester.pumpAndSettle();
expect(find.text('«Test Diet» предложена пользователю Test User'), findsOneWidget);
expect(find.text('Chat chat-1'), findsOneWidget);
verify(cubit.attachDietToConsultation(consultationId: 1)).called(1);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(find.text('Chats'), findsOneWidget);
});
});
testWidgets('opens chat after successful diet reattachment', (tester) async {
await mockNetworkImagesFor(() async {
final cubit = MockPersonalDietSuggestionCubit();
final consultationWithDiet = ConsultationModel(
id: 1,
chatId: 'chat-2',
chosenPlanState: ChosenPlanStateEnum.stateInProgress,
planTitle: 'Test Plan',
userName: 'Test User',
userSlug: 'test-user',
consultantPayment: 1000.0,
duration: 7,
currentDiaryDay: 3,
userProfilePhotoUrl: '/avatar.jpg',
consultationOptionPrice: 500,
startDate: DateTime(2025, 1, 1),
endDate: DateTime(2025, 1, 8),
consultantId: 1,
diaries: [],
personalDietId: 1,
);
when(cubit.state).thenReturn(PersonalDietSuggestionState());
when(cubit.stream).thenAnswer((_) => const Stream.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.reattachDietToConsultation(consultationId: 1)).thenAnswer((_) async => true);
await tester.pumpWidget(
BlocProvider<PersonalDietSuggestionCubit>.value(
value: cubit,
child: wrap(ConsultationWidget(consultationModel: consultationWithDiet, dietName: 'Test Diet')),
),
);
await tester.pumpAndSettle();
final changeButton = find.text(S.of(tester.element(find.byType(ConsultationWidget))).changeDiet);
await tester.tap(changeButton);
await tester.pumpAndSettle();
final offerButton = find.text(S.of(tester.element(find.byType(ConsultationWidget))).toOffer);
await tester.tap(offerButton);
await tester.pumpAndSettle();
expect(find.text('Chat chat-2'), findsOneWidget);
verify(cubit.reattachDietToConsultation(consultationId: 1)).called(1);
verifyNever(cubit.attachDietToConsultation(consultationId: 1));
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(find.text('Chats'), findsOneWidget);
});
});
testWidgets('stays on screen when chat id is missing after success', (tester) async {
await mockNetworkImagesFor(() async {
final cubit = MockPersonalDietSuggestionCubit();
when(cubit.state).thenReturn(PersonalDietSuggestionState());
when(cubit.stream).thenAnswer((_) => const Stream.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.attachDietToConsultation(consultationId: 1)).thenAnswer((_) async => true);
await tester.pumpWidget(
BlocProvider<PersonalDietSuggestionCubit>.value(
value: cubit,
child: wrap(
ConsultationWidget(
consultationModel: mockConsultation.copyWith(chatId: null),
dietName: 'Test Diet',
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Предложить диету'));
await tester.pumpAndSettle();
await tester.tap(find.text(S.of(tester.element(find.byType(ConsultationWidget))).toOffer));
await tester.pumpAndSettle();
expect(find.textContaining('Chat '), findsNothing);
expect(find.text('Test User'), findsOneWidget);
verify(cubit.attachDietToConsultation(consultationId: 1)).called(1);
});
});
@@ -279,7 +399,7 @@ void main() {
expect(find.text('Test User'), findsOneWidget);
expect(find.text('Test Plan'), findsOneWidget);
expect(find.text('Старт: Test User'), findsOneWidget);
expect(find.text('Старт: 01.01.2025'), findsOneWidget);
});
});
@@ -318,7 +438,71 @@ void main() {
expect(find.text('Test User'), findsOneWidget);
expect(find.text('Test Plan'), findsOneWidget);
expect(find.text('Старт: Test User'), findsNothing);
expect(find.text('Старт: 01.01.2025'), findsNothing);
});
});
testWidgets('disables CTA when plan duration differs from diet duration', (tester) async {
await mockNetworkImagesFor(() async {
final cubit = MockPersonalDietSuggestionCubit();
final consultation = ConsultationModel(
id: 1,
chosenPlanState: ChosenPlanStateEnum.stateInProgress,
planTitle: 'Test Plan',
userName: 'Test User',
userSlug: 'test-user',
consultantPayment: 1000.0,
duration: 14,
currentDiaryDay: 3,
userProfilePhotoUrl: '/avatar.jpg',
consultationOptionPrice: 500,
startDate: DateTime(2025, 1, 1),
endDate: DateTime(2025, 1, 15),
consultantId: 1,
diaries: [],
personalDietId: null,
);
when(cubit.state).thenReturn(PersonalDietSuggestionState());
when(cubit.stream).thenAnswer((_) => const Stream.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
await tester.pumpWidget(
BlocProvider<PersonalDietSuggestionCubit>.value(
value: cubit,
child: wrap(
ConsultationWidget(
consultationModel: consultation,
dietName: 'Test Diet',
dietDuration: 7,
),
),
),
);
await tester.pumpAndSettle();
final s = S.of(tester.element(find.byType(ConsultationWidget)));
expect(
find.text(
s.personal_diet_duration_mismatch(
7,
getDayForm(7, s),
14,
getDayForm(14, s),
),
),
findsOneWidget,
);
final button = tester.widget<OutlinedButton>(find.byType(OutlinedButton));
expect(button.onPressed, isNull);
expect(
button.style?.backgroundColor?.resolve({WidgetState.disabled}),
appTheme.dividerColor,
);
expect(
tester.widget<Text>(find.text(s.suggestDiet)).style?.color,
appTheme.gray,
);
});
});
});

View File

@@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/localization_utils.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart';
@@ -84,5 +85,31 @@ void main() {
expect(find.textContaining('Test Diet Name'), findsOneWidget);
});
testWidgets('shows diet duration hint and empty state', (tester) async {
final cubit = MockPersonalDietSuggestionCubit();
const dietDuration = 7;
final state = PersonalDietSuggestionState(
isLoading: false,
dietName: 'Test Diet',
dietDuration: dietDuration,
consultationsWithDiet: [],
consultationsWithoutDiet: [],
);
when(cubit.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
await tester.pumpWidget(wrap(const PersonalDietSuggestionScreenContent(), cubit));
await tester.pump();
final s = S.of(tester.element(find.byType(PersonalDietSuggestionScreenContent)));
expect(
find.text(s.personal_diet_duration_hint(dietDuration, getDayForm(dietDuration, s))),
findsOneWidget,
);
expect(find.text(s.no_active_personal_diet_consultations), findsOneWidget);
});
});
}

View File

@@ -68,41 +68,33 @@ void main() {
test('attachPersonalDiet - успешное прикрепление диеты', () async {
const consultationId = 1;
const dietId = 2;
final body = {'diet_id': dietId};
when(mockService.attachPersonalDiet(
consultationId: consultationId,
dietId: dietId,
)).thenAnswer((_) async => {});
when(mockService.attachPersonalDiet(consultationId, body))
.thenAnswer((_) async => {});
await repository.attachPersonalDiet(
consultationId: consultationId,
dietId: dietId,
);
verify(mockService.attachPersonalDiet(
consultationId: consultationId,
dietId: dietId,
)).called(1);
verify(mockService.attachPersonalDiet(consultationId, body)).called(1);
});
test('reattachPersonalDiet - успешное переприкрепление диеты', () async {
const consultationId = 1;
const dietId = 2;
final body = {'diet_id': dietId};
when(mockService.reattachPersonalDiet(
consultationId: consultationId,
dietId: dietId,
)).thenAnswer((_) async => {});
when(mockService.reattachPersonalDiet(consultationId, body))
.thenAnswer((_) async => {});
await repository.reattachPersonalDiet(
consultationId: consultationId,
dietId: dietId,
);
verify(mockService.reattachPersonalDiet(
consultationId: consultationId,
dietId: dietId,
)).called(1);
verify(mockService.reattachPersonalDiet(consultationId, body)).called(1);
});
});
}

View File

@@ -15,55 +15,48 @@ void main() {
setUp(() {
mockDio = MockDio();
when(mockDio.options).thenReturn(BaseOptions(baseUrl: 'http://localhost'));
service = PersonalDietSuggestionService(mockDio);
});
test('getConsultationWithPersonalDiets - успешное получение консультаций', () async {
final mockData = {
'data': [],
'links': {},
'meta': {
'current_page': 1,
'last_page': 1,
'total': 0,
'per_page': 10,
},
};
late RequestOptions capturedRequest;
when(mockDio.get(
'/api/consultations',
queryParameters: anyNamed('queryParameters'),
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: '/api/consultations'),
statusCode: 200,
data: mockData,
));
when(mockDio.fetch<Map<String, dynamic>>(any)).thenAnswer((invocation) async {
capturedRequest = invocation.positionalArguments.first as RequestOptions;
return Response<Map<String, dynamic>>(
requestOptions: capturedRequest,
statusCode: 200,
data: mockData,
);
});
final result = await service.getConsultationWithPersonalDiets();
expect(result, isA<ConsultationPaginationModel>());
verify(mockDio.get(
'/api/consultations',
queryParameters: {
'filters[plan][has_personal_diet][\$personal_diet]': '1',
'filters[last_state][\$notIn][]': [
'done',
'charge_was_collected',
'complained',
'last_day_finished',
'canceled_by_the_initiator',
'inited'
],
'sort[]': 'start_date:desc',
},
)).called(1);
expect(capturedRequest.path, '/api/consultations');
expect(capturedRequest.method, 'GET');
expect(capturedRequest.queryParameters, {
r'filters[plan][has_personal_diet][$personal_diet]': '1',
r'filters[last_state][$notIn][]': [
'done',
'charge_was_collected',
'complained',
'last_day_finished',
'last_day_started',
'canceled_by_the_initiator',
'inited',
],
r'sort[]': ['start_date:desc'],
});
verify(mockDio.fetch<Map<String, dynamic>>(any)).called(1);
});
test('getConsultationWithPersonalDiets - пробрасывает ошибку', () async {
when(mockDio.get(
any,
queryParameters: anyNamed('queryParameters'),
)).thenThrow(DioException(
when(mockDio.fetch<Map<String, dynamic>>(any)).thenThrow(DioException(
requestOptions: RequestOptions(path: '/api/consultations'),
));
@@ -75,74 +68,70 @@ void main() {
test('attachPersonalDiet - успешное прикрепление диеты', () async {
const consultationId = 1;
const dietId = 2;
final body = {'diet_id': 2};
late RequestOptions capturedRequest;
when(mockDio.post(
'/api/chosen_plans/$consultationId/attach_diet',
data: anyNamed('data'),
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/attach_diet'),
statusCode: 200,
));
when(mockDio.fetch<void>(any)).thenAnswer((invocation) async {
capturedRequest = invocation.positionalArguments.first as RequestOptions;
return Response<void>(
requestOptions: capturedRequest,
statusCode: 200,
);
});
await service.attachPersonalDiet(consultationId: consultationId, dietId: dietId);
await service.attachPersonalDiet(consultationId, body);
verify(mockDio.post(
'/api/chosen_plans/$consultationId/attach_diet',
data: {'diet_id': dietId},
)).called(1);
expect(capturedRequest.path, '/api/chosen_plans/$consultationId/attach_diet');
expect(capturedRequest.method, 'POST');
expect(capturedRequest.data, body);
verify(mockDio.fetch<void>(any)).called(1);
});
test('attachPersonalDiet - пробрасывает ошибку', () async {
const consultationId = 1;
const dietId = 2;
final body = {'diet_id': 2};
when(mockDio.post(
any,
data: anyNamed('data'),
)).thenThrow(DioException(
when(mockDio.fetch<void>(any)).thenThrow(DioException(
requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/attach_diet'),
));
expect(
() => service.attachPersonalDiet(consultationId: consultationId, dietId: dietId),
() => service.attachPersonalDiet(consultationId, body),
throwsA(isA<DioException>()),
);
});
test('reattachPersonalDiet - успешное повторное прикрепление диеты', () async {
const consultationId = 1;
const dietId = 3;
final body = {'diet_id': 3};
late RequestOptions capturedRequest;
when(mockDio.post(
'/api/chosen_plans/$consultationId/re_attach_diet',
data: anyNamed('data'),
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/re_attach_diet'),
statusCode: 200,
));
when(mockDio.fetch<void>(any)).thenAnswer((invocation) async {
capturedRequest = invocation.positionalArguments.first as RequestOptions;
return Response<void>(
requestOptions: capturedRequest,
statusCode: 200,
);
});
await service.reattachPersonalDiet(consultationId: consultationId, dietId: dietId);
await service.reattachPersonalDiet(consultationId, body);
verify(mockDio.post(
'/api/chosen_plans/$consultationId/re_attach_diet',
data: {'diet_id': dietId},
)).called(1);
expect(capturedRequest.path, '/api/chosen_plans/$consultationId/re_attach_diet');
expect(capturedRequest.method, 'POST');
expect(capturedRequest.data, body);
verify(mockDio.fetch<void>(any)).called(1);
});
test('reattachPersonalDiet - пробрасывает ошибку', () async {
const consultationId = 1;
const dietId = 3;
final body = {'diet_id': 3};
when(mockDio.post(
any,
data: anyNamed('data'),
)).thenThrow(DioException(
when(mockDio.fetch<void>(any)).thenThrow(DioException(
requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/re_attach_diet'),
));
expect(
() => service.reattachPersonalDiet(consultationId: consultationId, dietId: dietId),
() => service.reattachPersonalDiet(consultationId, body),
throwsA(isA<DioException>()),
);
});

View File

@@ -61,6 +61,47 @@ void main() {
expect(ChosenPlanStateEnum.stateConfirmedByConsultant.isStateAfterPaidState(), isTrue);
});
test('canShowCancelAction returns true for cancellable states', () {
expect(ChosenPlanStateEnum.stateInited.canShowCancelAction, isTrue);
expect(ChosenPlanStateEnum.stateInCoordination.canShowCancelAction, isTrue);
expect(ChosenPlanStateEnum.stateStartDateHasBeenSet.canShowCancelAction, isTrue);
expect(ChosenPlanStateEnum.statePendingPayment.canShowCancelAction, isTrue);
expect(ChosenPlanStateEnum.statePaid.canShowCancelAction, isTrue);
expect(ChosenPlanStateEnum.stateStarted.canShowCancelAction, isTrue);
expect(ChosenPlanStateEnum.stateInProgress.canShowCancelAction, isTrue);
});
test('canShowCancelAction returns false for non-cancellable states', () {
expect(ChosenPlanStateEnum.personalDietSet.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.stateLastDayStarted.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.stateLastDayFinished.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.stateComplained.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.stateDone.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.stateChargeWasCollected.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.stateCanceledByTheInitiator.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.statePaymentFailed.canShowCancelAction, isFalse);
expect(ChosenPlanStateEnum.stateConfirmedByConsultant.canShowCancelAction, isFalse);
});
test('isCancelAfterPaidState returns true only for post-paid cancellable states', () {
expect(ChosenPlanStateEnum.statePaid.isCancelAfterPaidState, isTrue);
expect(ChosenPlanStateEnum.stateStarted.isCancelAfterPaidState, isTrue);
expect(ChosenPlanStateEnum.stateInProgress.isCancelAfterPaidState, isTrue);
expect(ChosenPlanStateEnum.stateInited.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateInCoordination.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateStartDateHasBeenSet.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.statePendingPayment.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.statePaymentFailed.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateConfirmedByConsultant.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.personalDietSet.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateLastDayStarted.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateLastDayFinished.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateComplained.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateDone.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateChargeWasCollected.isCancelAfterPaidState, isFalse);
expect(ChosenPlanStateEnum.stateCanceledByTheInitiator.isCancelAfterPaidState, isFalse);
});
test('getActiveStates returns correct states', () {
final activeStates = ChosenPlanStateEnum.getActiveStates();

View File

@@ -60,6 +60,7 @@ void main() {
'last_state': 'initied',
'chat_id': 'chat-1',
'consultant_id': 1,
'personal_diet_name': 'Индивидуальный план питания',
'plan': {
'id': 1,
'slug': 'test-plan',
@@ -94,6 +95,7 @@ void main() {
expect(chosenPlan.optionType, 'options1');
expect(chosenPlan.chosenPlanState, ChosenPlanStateEnum.stateInited);
expect(chosenPlan.chatId, 'chat-1');
expect(chosenPlan.personalDietName, 'Индивидуальный план питания');
});
test('toJson serializes ChosenPlanModel correctly', () {
@@ -198,6 +200,66 @@ void main() {
});
});
group('chosenOption', () {
const plan = 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,
packet1Options: PacketOptions(items: ['one'], price: 100, short: 'packet-1', hasPersonalDiet: false),
packet2Options: PacketOptions(items: ['two'], price: 200, short: 'packet-2', hasPersonalDiet: true),
packet3Options: PacketOptions(items: ['three'], price: 300, short: 'packet-3', hasPersonalDiet: false),
author: Author(
authorSlug: 'author',
authorName: 'Author Name',
authorProfileIcon: 'https://example.com/icon.jpg',
authorIsMe: false,
isEntitySubscribedByMe: null,
authorPlaceInRating: null,
),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
isFavorite: false,
minPriceOfOptions: 100,
maxPriceOfOptions: 300,
wasLikedByMe: false,
);
ChosenPlanModel modelWith(String optionType) => ChosenPlanModel(
id: 1,
optionType: optionType,
startDate: DateTime(2024, 1, 1),
chosenPlanState: ChosenPlanStateEnum.stateInited,
chatId: 'chat-1',
consultantId: 1,
plan: plan,
);
test('возвращает первую опцию для options1', () {
expect(modelWith('options1').chosenOption?.short, 'packet-1');
});
test('возвращает вторую опцию для options2', () {
expect(modelWith('options2').chosenOption?.short, 'packet-2');
});
test('возвращает третью опцию для options3', () {
expect(modelWith('options3').chosenOption?.short, 'packet-3');
});
test('возвращает null для неизвестного значения', () {
expect(modelWith('unknown').chosenOption, isNull);
expect(modelWith('1').chosenOption, isNull);
expect(modelWith('').chosenOption, isNull);
});
});
group('parseChosenPlan', () {
test('parseChosenPlan создает ChosenPlanModel из Map', () {
final json = {
@@ -207,6 +269,7 @@ void main() {
'last_state': 'initied',
'chat_id': 'chat-1',
'consultant_id': 1,
'personal_diet_name': 'Индивидуальный план питания',
'plan': {
'id': 1,
'slug': 'test-plan',