feat(feed): fixed comments state updating in feed, feat(consultantRegistration): fixed registration flow for users with unverified phone, feat(tests): fixed tests coverage

This commit is contained in:
misha
2026-04-01 10:37:59 +03:00
parent ee2dd75a94
commit a13b623ca2
20 changed files with 949 additions and 343 deletions

View File

@@ -74,7 +74,10 @@ class AppRouter {
FlutterNativeSplash.remove();
return '/about-diet-screen?slug=$contentIdentifier';
}
final initialTabIndex = hidePlans ? 3 : 4;
const dietTabIndexWithHiddenPlansTab = 3;
const dietTabIndexWithoutHiddenPlansTab = 4;
final initialTabIndex = hidePlans ? dietTabIndexWithHiddenPlansTab : dietTabIndexWithoutHiddenPlansTab;
return '${RouteConstants.feedScreen}?initialTabIndex=$initialTabIndex';
}

View File

@@ -10,10 +10,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final FeedRepository feedRepository;
final CommentsRepository commentsRepository;
CommentsCubit({
required this.feedRepository,
required this.commentsRepository,
}) : super(const CommentsState());
CommentsCubit({required this.feedRepository, required this.commentsRepository}) : super(const CommentsState());
void setReplyId(int? replyId) {
emit((state as CommentsStateLoaded).copyWith(replyId: replyId));
@@ -41,46 +38,27 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
Future<void> sendComment({
required String id,
required String commentText,
required CommentSourceType commentSourceType,
int? parentCommentId,
}) async {
Future<bool> sendComment({required String id, required String commentText, required CommentSourceType commentSourceType, int? parentCommentId}) async {
try {
switch (commentSourceType) {
case CommentSourceType.blogPost:
await feedRepository.sendBlogPostComment(
id: id,
commentText: commentText,
parentCommentId: parentCommentId,
);
await feedRepository.sendBlogPostComment(id: id, commentText: commentText, parentCommentId: parentCommentId);
break;
case CommentSourceType.diet:
await feedRepository.sendDietsComment(
id: id,
commentText: commentText,
parentCommentId: parentCommentId,
);
await feedRepository.sendDietsComment(id: id, commentText: commentText, parentCommentId: parentCommentId);
break;
case CommentSourceType.recipe:
await feedRepository.sendRecipesComment(
id: id,
commentText: commentText,
parentCommentId: parentCommentId,
);
await feedRepository.sendRecipesComment(id: id, commentText: commentText, parentCommentId: parentCommentId);
break;
case CommentSourceType.plan:
await feedRepository.sendPlansComment(
slug: id,
commentText: commentText,
parentCommentId: parentCommentId,
);
await feedRepository.sendPlansComment(slug: id, commentText: commentText, parentCommentId: parentCommentId);
break;
}
getComments(id, commentSourceType);
return true;
} catch (e) {
emit(CommentsState.error(errorMessage: e.toString()));
return false;
}
}
@@ -101,8 +79,9 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<bool> updateComment({required int id, required String body}) async {
final commentModel = (state as CommentsStateLoaded).comment;
final updatedCommentModel =
commentModel.copyWith(data: commentModel.data.map((comment) => comment.id == id ? comment.copyWith(body: body) : comment).toList());
final updatedCommentModel = commentModel.copyWith(
data: commentModel.data.map((comment) => comment.id == id ? comment.copyWith(body: body) : comment).toList(),
);
try {
await commentsRepository.updateComment(id: id, body: body);
emit(CommentsState.loaded(comment: updatedCommentModel));

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:go_router/go_router.dart';
@@ -12,18 +13,21 @@ class PhoneVerifyErrorInterceptor extends Interceptor {
final data = err.response?.data;
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
final String phone = await sl<ProfileRepository>().getMyPhone();
final String phone = await sl<ProfileRepository>().getMyPhone();
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
await showErrorDialog(AppRouter.navigatorKey.currentContext!,
title: 'Привяжите номер телефона', description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона');
await showErrorDialog(
AppRouter.navigatorKey.currentContext!,
title: 'Привяжите номер телефона',
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона',
);
final currentContext = AppRouter.navigatorKey.currentContext;
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) {
GoRouter.of(currentContext).pop();
}
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
return handler.reject(
DioException(
requestOptions: err.requestOptions,
response: err.response,
error: 'Phone number not verified',
type: DioExceptionType.badResponse,
),
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse),
);
}
}

View File

@@ -47,8 +47,8 @@ class MainNavigationRoutes {
path: RouteConstants.feedScreen,
name: 'feedScreen',
pageBuilder: (context, state) {
final initialTabIndex = int.tryParse(state.uri.queryParameters['initialTabIndex'] ?? '0');
return NoTransitionPage(child: FeedScreen(initialTabIndex: initialTabIndex ?? 0));
final initialTabIndex = int.tryParse(state.uri.queryParameters['initialTabIndex'] ?? '');
return NoTransitionPage(child: FeedScreen(initialTabIndex: initialTabIndex));
},
redirect: (context, state) async {
try {

View File

@@ -78,9 +78,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
return Scaffold(
resizeToAvoidBottomInset: true,
bottomSheet: Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 8,
),
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
@@ -147,14 +145,18 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
onTap: () async {
if (textEditingController.text.length > 2) {
commentsCubit.sendComment(
final isSent = await commentsCubit.sendComment(
id: widget.id.toString(),
commentText: textEditingController.text,
parentCommentId: replyId,
commentSourceType: CommentSourceType.blogPost,
);
if (!context.mounted) return;
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
context.read<CommentsCubit>().setReplyId(null);
setState(() {
textEditingController.clear();
@@ -237,9 +239,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: MarkdownHtmlWidget(
markdownText: blogPostState.blogPostModel!.description,
),
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
),
const SizedBox(height: 8),
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
@@ -20,45 +21,53 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
);
Future<void> getMyRequestStatus(String slug) async {
_update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
final certPhotos = await consultantRepository.getConsultantCertPhotos();
try {
_update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
final certPhotos = await consultantRepository.getConsultantCertPhotos();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
final course = courseMap.values.first;
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
final course = courseMap.values.first;
return CertificatesModel(title: course.title, date: course.date);
}).toList();
return CertificatesModel(title: course.title, date: course.date);
}).toList();
String? sanitizeLink(String? link) {
return (link != null && link.isNotEmpty) ? link : null;
}
String? sanitizeLink(String? link) {
return (link != null && link.isNotEmpty) ? link : null;
}
emit(
RegisterConsultantState(
consultant: ConsultantProfileModel(
certificates: certificates,
socialLinks: SocialLinksModel(
vkLink: sanitizeLink(myRequestStatus.educationModel.vkLink),
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
emit(
RegisterConsultantState(
consultant: ConsultantProfileModel(
certificates: certificates,
socialLinks: SocialLinksModel(
vkLink: sanitizeLink(myRequestStatus.educationModel.vkLink),
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
),
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
),
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
acceptOffer: false,
loading: false,
requestModel: myRequestStatus,
certPhotos: certPhotos,
isUpdating: _update,
),
acceptOffer: false,
loading: false,
requestModel: myRequestStatus,
certPhotos: certPhotos,
isUpdating: _update
),
);
);
} catch (e) {
log(e.toString());
}
}
Future<void> disableLoading() async {
final certPhotos = await consultantRepository.getConsultantCertPhotos();
emit(state.copyWith(loading: false, certPhotos: certPhotos));
try {
final certPhotos = await consultantRepository.getConsultantCertPhotos();
emit(state.copyWith(loading: false, certPhotos: certPhotos));
} catch (e) {
log(e.toString());
}
}
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {

View File

@@ -17,18 +17,13 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
title: const Text('Стать консультантом'),
automaticallyImplyLeading: false,
centerTitle: false,
leading: IconButton(
onPressed: () => GoRouter.of(context).pop(),
icon: const Icon(Icons.close),
),
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.',
style: TextStyle(
fontSize: 16,
),
style: TextStyle(fontSize: 16),
),
),
bottomSheet: _startRegistrationButton(),
@@ -37,24 +32,16 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
InkWell _startRegistrationButton() {
return InkWell(
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen'),
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: const Center(
child: Text(
'Начать регистрацию',
style: TextStyle(
color: Colors.white,
height: 1,
fontWeight: FontWeight.w600,
fontSize: 18,
),
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
),
),
),

View File

@@ -26,6 +26,17 @@ class FeedCubit extends Cubit<FeedState> {
}
}
void incrementCommentsState(int id) {
final List<FeedBlogPostModel> feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList];
final int index = feedBlogPostList.indexWhere((e) => e.id == id);
if (index == -1) return;
final current = feedBlogPostList[index];
feedBlogPostList[index] = current.copyWith(totalComments: current.totalComments + 1);
if (!isClosed) {
emit(FeedState.loaded(feedBlogPostList: feedBlogPostList, isLoading: isLoading));
}
}
Future<void> loadFeedBlogPostList({String? query}) async {
try {
_query = query;

View File

@@ -33,7 +33,7 @@ import '../widgets/recipes.dart';
class FeedScreen extends StatelessWidget {
const FeedScreen({super.key, this.initialTabIndex = 0});
final int initialTabIndex;
final int? initialTabIndex;
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
@@ -51,7 +51,7 @@ class FeedScreen extends StatelessWidget {
class FeedScreenContent extends StatefulWidget {
const FeedScreenContent({super.key, required this.initialTabIndex});
final int initialTabIndex;
final int? initialTabIndex;
@override
State<FeedScreenContent> createState() => _FeedScreenContentState();
}
@@ -72,7 +72,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
final consultantsScrollController = ScrollController();
@override
void initState() {
_selectedChipIndex = widget.initialTabIndex;
_selectedChipIndex = widget.initialTabIndex ?? 0;
chipsLabelList = hidePlans ? ['Все', 'Консультанты', 'Рецепты', 'Диеты'] : ['Все', 'Планы питания', 'Консультанты', 'Рецепты', 'Диеты'];
consultantsScrollController.addListener(_onScrollConsultants);
_checkRegistrationDate(context);
@@ -92,8 +92,11 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
@override
void didUpdateWidget(FeedScreenContent oldWidget) {
super.didUpdateWidget(oldWidget);
final initialTabIndex = widget.initialTabIndex;
setState(() {
_selectedChipIndex = widget.initialTabIndex;
if (initialTabIndex != null) {
_selectedChipIndex = initialTabIndex;
}
});
}

View File

@@ -13,9 +13,12 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
final PlanRepository planRepository;
AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial());
Future<void> getPlan(String slug) async {
Future<void> getPlan({required String slug, int? index}) async {
try {
final plan = await planRepository.getPlan(slug);
if (index != null) {
planRepository.emitCommentsStateToFeed(CommentsState(index: index, totalComments: plan.totalComments));
}
emit(state.copyWith(planModel: plan, error: null));
} catch (e, stackTrace) {
log(e.toString(), stackTrace: stackTrace);
@@ -27,13 +30,7 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
try {
emit(state.copyWith(isLoading: true));
final plan = await planRepository.getPlan(slug);
emit(
state.copyWith(
planModel: plan,
error: null,
isLoading: false,
),
);
emit(state.copyWith(planModel: plan, error: null, isLoading: false));
} catch (e, stackTrace) {
log(e.toString(), stackTrace: stackTrace);
emit(state.copyWith(error: e.toString(), isLoading: false));
@@ -59,21 +56,17 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
}
}
EasyDebounce.debounce(
'like_plan_$slug',
const Duration(seconds: 1),
() async {
try {
await planRepository.likePlan(slug);
} catch (e) {
log(e.toString());
if (index != null) {
planRepository.emitLikesStateToFeed(LikesState(wasLikedByMe: currentPlan.wasLikedByMe, totalLikes: currentPlan.totalLikes, index: index));
}
emit(state.copyWith(planModel: currentPlan, error: null));
EasyDebounce.debounce('like_plan_$slug', const Duration(seconds: 1), () async {
try {
await planRepository.likePlan(slug);
} catch (e) {
log(e.toString());
if (index != null) {
planRepository.emitLikesStateToFeed(LikesState(wasLikedByMe: currentPlan.wasLikedByMe, totalLikes: currentPlan.totalLikes, index: index));
}
},
);
emit(state.copyWith(planModel: currentPlan, error: null));
}
});
}
Future<void> addToFavorite({required String slug, required int? index}) async {
@@ -84,22 +77,18 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
if (index != null) {
planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: true, index: index));
}
EasyDebounce.debounce(
'add_or_remove_plan_to_favorites',
const Duration(seconds: 1),
() async {
try {
await planRepository.addToFavorite(slug);
} catch (e) {
log(e.toString());
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
if (index != null) {
planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index));
}
emit(state.copyWith(planModel: currentPlan, error: null));
EasyDebounce.debounce('add_or_remove_plan_to_favorites', const Duration(seconds: 1), () async {
try {
await planRepository.addToFavorite(slug);
} catch (e) {
log(e.toString());
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
if (index != null) {
planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index));
}
},
);
emit(state.copyWith(planModel: currentPlan, error: null));
}
});
}
Future<void> removeFromFavorite({required String slug, required int? index}) async {
@@ -110,22 +99,18 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
if (index != null) {
planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: false, index: index));
}
EasyDebounce.debounce(
'add_or_remove_plan_to_favorites',
const Duration(seconds: 1),
() async {
try {
await planRepository.removeFromFavorite(slug);
} catch (e) {
log(e.toString());
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
if (index != null) {
planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index));
}
emit(state.copyWith(planModel: currentPlan, error: null));
EasyDebounce.debounce('add_or_remove_plan_to_favorites', const Duration(seconds: 1), () async {
try {
await planRepository.removeFromFavorite(slug);
} catch (e) {
log(e.toString());
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
if (index != null) {
planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index));
}
},
);
emit(state.copyWith(planModel: currentPlan, error: null));
}
});
}
Future<(ChosenPlanModel?, String?)> chosePlan() async {
@@ -149,8 +134,8 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
}
String _getOptionNameFromIndex(int index) => switch (index) {
1 => 'options2',
2 => 'options3',
_ => 'options1',
};
1 => 'options2',
2 => 'options3',
_ => 'options1',
};
}

View File

@@ -154,7 +154,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
const SizedBox(width: 16),
],
),
body: DataErrorWidget(onReload: () => context.read<AboutPlanCubit>().getPlan(widget.slug)),
body: DataErrorWidget(onReload: () => context.read<AboutPlanCubit>().getPlan(slug: widget.slug)),
);
}
return Scaffold(
@@ -295,7 +295,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
Future.delayed(const Duration(milliseconds: 500), () {
if (context.mounted) {
context.read<AboutPlanCubit>().getPlan(widget.slug);
context.read<AboutPlanCubit>().getPlan(slug: widget.slug, index: widget.index);
}
});
FocusManager.instance.primaryFocus?.unfocus();

View File

@@ -112,5 +112,43 @@ void main() {
expect(() => AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'vk'), returnsNormally);
expect(() => AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'google'), returnsNormally);
});
test('navigateByDeepLinkUrl handles empty and invalid urls', () {
expect(() => AppRouter.navigateByDeepLinkUrl(''), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://unknown-host.example/foo'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('%%%'), returnsNormally);
});
test('navigateByDeepLinkUrl handles supported recipe and blog links', () {
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/recipe/test-recipe'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.com/en/blog_post/123'), returnsNormally);
});
test('navigateByDeepLinkUrl handles plan links', () {
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/plan/plan-slug'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/plan/42'), returnsNormally);
});
test('navigateByDeepLinkUrl handles diet links', () {
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diet/99'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diet/diet-slug'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diet'), returnsNormally);
});
test('navigateByDeepLinkUrl handles chat links', () {
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/chat/uuid-1'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/chat/show/uuid-2'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/chat'), returnsNormally);
});
test('navigateByDeepLinkUrl handles profile and misc links', () {
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/profile/some-user'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/profile'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/badge/1'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diary'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/consultant_diary_comment/7'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/consultations/status/11'), returnsNormally);
expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/consultations'), returnsNormally);
});
});
}

View File

@@ -205,5 +205,54 @@ void main() {
expect(find.text('Test Post Title'), findsOneWidget);
});
});
testWidgets('loaded state tapping like invokes toggleLike', (tester) async {
await mockNetworkImagesFor(() async {
final blogPostCubit = MockBlogPostCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
final blogPostModel = FeedBlogPostModel(
id: 1,
title: 'Test Post Title',
description: 'Test post description',
image: 'https://example.com/image.jpg',
state: 'published',
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
videoUrl: '',
createdAt: DateTime(2024, 1, 15),
updatedAt: DateTime(2024, 1, 15),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
wasLikedByMe: false,
);
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
when(blogPostCubit.state).thenReturn(state);
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
when(blogPostCubit.getBlogPost(any)).thenAnswer((_) async => Future<void>.value());
when(blogPostCubit.toggleLike()).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(feedCubit.updateFeedBlogPostList(any, any)).thenReturn(null);
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
registerMocks(blogPostCubit, commentsCubit);
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, const BlogPostScreen(id: 1)));
await tester.pump();
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.favorite_border_rounded));
await tester.pump();
verify(blogPostCubit.toggleLike()).called(1);
});
});
});
}

View File

@@ -1,4 +1,9 @@
import 'dart:io';
import 'package:dio/dio.dart';
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/models/pagination_model.dart';
import 'package:eatvio_app/features/create_recipe/models/ingredient.dart';
import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart';
@@ -8,31 +13,123 @@ import 'package:eatvio_app/features/create_recipe/models/step.dart';
import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart';
import 'package:eatvio_app/features/create_recipe/services/recipe_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:mocktail/mocktail.dart';
import 'package:retrofit/retrofit.dart';
class MockCreateRecipeService extends Mock implements CreateRecipeService {}
final mockHttpResponse = HttpResponse(
null,
Response(requestOptions: RequestOptions(path: ''), statusCode: 200),
);
final mockHttpResponse = HttpResponse(null, Response(requestOptions: RequestOptions(path: ''), statusCode: 200));
void main() {
setUpAll(() {
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
if (sl.isRegistered<AppConfig>()) {
sl.unregister<AppConfig>();
}
sl.registerSingleton<AppConfig>(AppConfig(buildType: AppBuildType.tests));
registerFallbackValue(FormData());
});
group('RecipeRepository', () {
late RecipeRepository repository;
late MockCreateRecipeService mockService;
late Directory tempDir;
late File recipeImage;
late File stepImage;
setUp(() {
mockService = MockCreateRecipeService();
repository = RecipeRepository(recipeService: mockService);
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('eatvio_recipe_repo_test_');
recipeImage = File('${tempDir.path}/recipe.png');
stepImage = File('${tempDir.path}/step.png');
// Valid 1x1 transparent PNG to satisfy ImageCompressUtility decoding.
const png1x1 = <int>[
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
];
await recipeImage.writeAsBytes(png1x1, flush: true);
await stepImage.writeAsBytes(png1x1, flush: true);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
test('inputProduct - возвращает Map с продуктами', () async {
const name = 'test-product';
const expected = {
@@ -177,12 +274,12 @@ void main() {
test('updateRecipe - вызывает updateRecipe с правильными параметрами', () async {
const slug = 'recipe-slug';
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Updated Recipe',
description: 'Updated description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
@@ -192,27 +289,29 @@ void main() {
energyPerServing: 300,
);
// Мокируем ImageCompressUtility.compressFile
// Это сложно, так как это статический метод, но мы можем проверить, что вызывается updateRecipe
when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(
() => mockService.updateRecipe(
slug: slug,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => Future.value(mockHttpResponse));
// Этот тест может упасть из-за ImageCompressUtility, но мы проверяем логику
try {
await repository.publishRecipe(recipe, true, slug);
verify(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe, true, slug);
verify(
() => mockService.updateRecipe(
slug: slug,
formData: any(named: 'formData'),
),
).called(1);
});
test('publishRecipe - обрабатывает рецепт с тегами', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: ['tag1', 'tag2'],
@@ -222,24 +321,19 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - обрабатывает рецепт с videoUrl', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: 'https://example.com/video.mp4',
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
@@ -249,24 +343,19 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - обрабатывает рецепт без videoUrl (null)', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
@@ -276,24 +365,19 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - обрабатывает рецепт с пустым videoUrl', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: '',
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
@@ -303,24 +387,19 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - пробрасывает исключение при ошибке', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
@@ -330,37 +409,21 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenThrow(Exception('Error'));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenThrow(Exception('Error'));
try {
await repository.publishRecipe(recipe);
fail('Should have thrown exception');
} catch (e) {
expect(e, isA<Exception>());
}
expect(() => repository.publishRecipe(recipe), throwsA(isA<Exception>()));
});
test('publishRecipe - обрабатывает рецепт с ингредиентами', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: [
Ingredient(
name: 'Apple',
unit: '100',
portion: 'grams',
productId: 1,
),
Ingredient(
name: 'Banana',
unit: '50',
portion: 'grams',
productId: 2,
),
const Ingredient(name: 'Apple', unit: '100', portion: 'grams', productId: 1),
const Ingredient(name: 'Banana', unit: '50', portion: 'grams', productId: 2),
],
steps: null,
tags: null,
@@ -370,31 +433,21 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - обрабатывает рецепт с null ингредиентами', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: [
Ingredient(
name: 'Apple',
unit: '100',
portion: 'grams',
productId: 1,
),
const Ingredient(name: 'Apple', unit: '100', portion: 'grams', productId: 1),
null, // null ингредиент должен быть отфильтрован
],
steps: null,
@@ -405,34 +458,23 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - обрабатывает рецепт со шагами без изображений', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: [
Step(
description: 'Step 1 description',
image: null,
),
Step(
description: 'Step 2 description',
image: '',
),
const Step(description: 'Step 1 description', image: null),
const Step(description: 'Step 2 description', image: ''),
],
tags: null,
totalCookingTime: 30,
@@ -441,35 +483,24 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - обрабатывает рецепт со смешанными шагами (с null)', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: [
Step(
description: 'Step 1 description',
image: null,
),
const Step(description: 'Step 1 description', image: null),
null, // null шаг должен быть отфильтрован
Step(
description: 'Step 3 description',
image: '',
),
const Step(description: 'Step 3 description', image: ''),
],
tags: null,
totalCookingTime: 30,
@@ -478,24 +509,19 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('publishRecipe - удаляет пустые значения из formMap', () async {
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: '', // Пустое описание должно быть удалено
videoUrl: '', // Пустой videoUrl должен быть удален
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
@@ -505,25 +531,20 @@ void main() {
energyPerServing: null,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value(mockHttpResponse));
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
try {
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
} catch (e) {
// Игнорируем ошибки, связанные с ImageCompressUtility в тестах
}
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('updateRecipe - пробрасывает исключение при ошибке', () async {
const slug = 'recipe-slug';
const recipe = RecipeModel(
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: '/test/path/image.jpg',
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
@@ -533,16 +554,132 @@ void main() {
energyPerServing: 300,
);
when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData')))
.thenThrow(Exception('Error'));
when(
() => mockService.updateRecipe(
slug: slug,
formData: any(named: 'formData'),
),
).thenThrow(Exception('Error'));
try {
await repository.publishRecipe(recipe, true, slug);
fail('Should have thrown exception');
} catch (e) {
expect(e, isA<Exception>());
}
expect(() => repository.publishRecipe(recipe, true, slug), throwsA(isA<Exception>()));
});
test('publishRecipe - собирает steps с изображением (MultipartFile)', () async {
final recipe = RecipeModel(
type: 'main',
title: 'Test Recipe',
description: 'Test description',
videoUrl: null,
image: recipeImage.path,
ingredients: null,
steps: [Step(description: 'Step with image', image: stepImage.path)],
tags: null,
totalCookingTime: 30,
activeCookTime: 15,
servings: 4,
energyPerServing: 300,
);
when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
await repository.publishRecipe(recipe);
verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1);
});
test('saveAsDraft - создает draft (createDraftedRecipe)', () async {
final recipe = RecipeModel(
type: 'main',
title: 'Draft title',
description: 'Draft desc',
videoUrl: null,
image: recipeImage.path,
ingredients: null,
steps: null,
tags: const ['tag1'],
totalCookingTime: 30,
activeCookTime: 15,
servings: 2,
energyPerServing: 200,
);
when(() => mockService.createDraftedRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse));
await repository.saveAsDraft(recipe);
verify(() => mockService.createDraftedRecipe(formData: any(named: 'formData'))).called(1);
verifyNever(
() => mockService.updateRecipe(
slug: any(named: 'slug'),
formData: any(named: 'formData'),
),
);
});
test('saveAsDraft - обновляет draft (updateRecipe)', () async {
const slug = 'draft-slug';
final recipe = RecipeModel(
type: 'main',
title: 'Draft title',
description: 'Draft desc',
videoUrl: null,
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
totalCookingTime: 30,
activeCookTime: 15,
servings: 2,
energyPerServing: 200,
);
when(
() => mockService.updateRecipe(
slug: slug,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => Future.value(mockHttpResponse));
await repository.saveAsDraft(recipe, true, slug);
verify(
() => mockService.updateRecipe(
slug: slug,
formData: any(named: 'formData'),
),
).called(1);
});
test('saveAsDraft - update=true без slug кидает исключение', () async {
final recipe = RecipeModel(
type: 'main',
title: 'Draft title',
description: 'Draft desc',
videoUrl: null,
image: recipeImage.path,
ingredients: null,
steps: null,
tags: null,
totalCookingTime: 30,
activeCookTime: 15,
servings: 2,
energyPerServing: 200,
);
// slug! должен упасть
expect(() => repository.saveAsDraft(recipe, true, null), throwsA(anything));
});
test('draftExistingRecipe - вызывает draftRecipe', () async {
const id = 123;
when(() => mockService.draftRecipe(id: id)).thenAnswer((_) async => Future.value());
await repository.draftExistingRecipe(id);
verify(() => mockService.draftRecipe(id: id)).called(1);
});
test('draftExistingRecipe - пробрасывает исключение при ошибке', () async {
const id = 123;
when(() => mockService.draftRecipe(id: id)).thenThrow(Exception('Error'));
expect(() => repository.draftExistingRecipe(id), throwsA(isA<Exception>()));
});
});
}

View File

@@ -3,6 +3,7 @@ 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/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -16,6 +17,7 @@ void main() {
setUp(() async {
await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
sl.registerLazySingleton<Dio>(() => Dio(BaseOptions(baseUrl: 'http://localhost')));
});
Widget wrap(Widget child) {
@@ -39,5 +41,29 @@ void main() {
expect(find.byType(DevToolsScreenContent), findsOneWidget);
});
testWidgets('updates base url from input by done button', (tester) async {
await tester.pumpWidget(wrap(const DevToolsScreen()));
await tester.pump();
await tester.enterText(find.byType(TextFormField).first, 'HTTP://EXAMPLE.COM');
await tester.tap(find.byIcon(Icons.done));
await tester.pump();
expect(find.byType(DevToolsScreenContent), findsOneWidget);
});
testWidgets('toggle base url button is tappable', (tester) async {
await tester.pumpWidget(wrap(const DevToolsScreen()));
await tester.pump();
final toggleButton = find.text('Set production base url');
expect(toggleButton, findsOneWidget);
await tester.tap(toggleButton);
await tester.pump();
expect(find.byType(DevToolsScreenContent), findsOneWidget);
});
});
}

View File

@@ -0,0 +1,107 @@
import 'package:eatvio_app/features/dev_tools/test_cases.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('DevTools TestCases bulk', () {
test('all case names are readable', () {
final cases = <TestCases>[
CreateRecipeTask(),
Test850task(),
GetChatRoomPreviews(),
GetChatRoom(),
SendMessage(),
GetChatMessages(),
GetChatRoomIdByUserSlug(),
GetBlogPostList(),
GetRecipes(),
GetDiets(),
GetPlans(),
GetBlogPostComments(),
SendBlogPostComment(),
GetDietsComments(),
SendDietsComment(),
GetPlansComments(),
SendPlansComment(),
GetRecipesComments(),
AuthServiceTest(),
DiaryServiceTest(),
BlockedUserServiceTest(),
PlanServiceTest(),
AboutRecipeServiceTest(),
BlogPostServiceTest(),
CalendarServiceTest(),
CardsServiceTest(),
ConsultantServiceTest(),
ConsultationServiceTest(),
CreateDietServiceTest(),
CreatePlanServiceTest(),
GlossaryServiceTest(),
MyDietsServiceTest(),
MyPlansServiceTest(),
MyRecipesServiceTest(),
NotificationsServiceTest(),
PlansServiceTest(),
ProfileServiceTest(),
RegistrationServiceTest(),
ResetPasswordServiceTest(),
SettingsServiceTest(),
WsServiceTest(),
];
for (final c in cases) {
expect(c.name, isNotEmpty);
}
});
test('run() does not throw for all cases', () async {
final cases = <TestCases>[
CreateRecipeTask(),
Test850task(),
GetChatRoomPreviews(),
GetChatRoom(),
SendMessage(),
GetChatMessages(),
GetChatRoomIdByUserSlug(),
GetBlogPostList(),
GetRecipes(),
GetDiets(),
GetPlans(),
GetBlogPostComments(),
SendBlogPostComment(),
GetDietsComments(),
SendDietsComment(),
GetPlansComments(),
SendPlansComment(),
GetRecipesComments(),
AuthServiceTest(),
DiaryServiceTest(),
BlockedUserServiceTest(),
PlanServiceTest(),
AboutRecipeServiceTest(),
BlogPostServiceTest(),
CalendarServiceTest(),
CardsServiceTest(),
ConsultantServiceTest(),
ConsultationServiceTest(),
CreateDietServiceTest(),
CreatePlanServiceTest(),
GlossaryServiceTest(),
MyDietsServiceTest(),
MyPlansServiceTest(),
MyRecipesServiceTest(),
NotificationsServiceTest(),
PlansServiceTest(),
ProfileServiceTest(),
RegistrationServiceTest(),
ResetPasswordServiceTest(),
SettingsServiceTest(),
WsServiceTest(),
];
for (final c in cases) {
final result = await c.run(baseUrl: 'http://localhost');
expect(result, isA<TestResult>());
}
});
});
}

View File

@@ -0,0 +1,121 @@
import 'package:eatvio_app/features/notifications/models/notification_model.dart';
import 'package:flutter_test/flutter_test.dart';
/// Smoke coverage for [fromJson] factories not exercised elsewhere — improves line coverage on large model file.
void main() {
const c = '2024-06-01T12:00:00Z';
final base = <String, dynamic>{'created_at': c};
final planAndUser = <String, dynamic>{'created_at': c, 'plan_name': 'p', 'user_name': 'u', 'renewal_date': '2024-06-02'};
final scoreLike = <String, dynamic>{
'created_at': c,
'score_key': 'k',
'scorable_id': 1,
'scorable_type': 'Type',
'score_value': 2,
};
final commentReply = <String, dynamic>{
'created_at': c,
'entity_name': 'e',
'commented_entity_id': 1,
'commented_entity_type': 'diet',
'author': 'a',
'author_id': 2,
'parrent_comment_id': 3,
};
final entityCommented = <String, dynamic>{
'created_at': c,
'entity_name': 'e',
'entity_id': 1,
'entity_type': 'diet',
'entity_traslated_type': 't',
'entity_url': 'u',
'author_name': 'a',
'author_id': 2,
'author_slug': 's',
};
group('CommentedEntityType.fromJson', () {
test('maps known and legacy keys', () {
expect(CommentedEntityType.fromJson('diet'), CommentedEntityType.diet);
expect(CommentedEntityType.fromJson('Modules\\UserGeneratedPublicContent\\Models\\Diet'), CommentedEntityType.diet);
expect(CommentedEntityType.fromJson('blog-post'), CommentedEntityType.blogPost);
expect(CommentedEntityType.fromJson('Modules\\UserGeneratedPublicContent\\Models\\BlogPost'), CommentedEntityType.blogPost);
expect(CommentedEntityType.fromJson('other'), CommentedEntityType.unknown);
});
});
group('notification fromJson bulk smoke', () {
test('executes remaining model factories with minimal JSON', () {
PlanSubscriptionExpiredNotification.fromJson(planAndUser, NotificationType.planSubscriptionExpired);
PlanSubscriptionRenewedNotification.fromJson(planAndUser, NotificationType.planSubscriptionRenewed);
ChosenPlanStartedToConsultantNotification.fromJson(base, NotificationType.chosenPlanStartedToConsultant);
ChosenPlanTomorrowReminderToConsultantNotification.fromJson(base, NotificationType.chosenPlanTomorrowReminderToConsultant);
PlanSubscriptionPaidToAuthorNotification.fromJson(base, NotificationType.planSubscriptionPaidToAuthor);
ChosenPlanFinishedToConsultantNotification.fromJson(base, NotificationType.chosenPlanFinishedToConsultant);
ChosenPlanLastDayFinishedForUser.fromJson(base, NotificationType.chosenPlanLastDayFinishedForUser);
ChosenPlanConsultantLastDayStarted.fromJson(base, NotificationType.chosenPlanConsultantLastDayStarted);
ComplaintToModerationForUser.fromJson(base, NotificationType.complaintToModerationForUser);
CommentReplied.fromJson(commentReply, NotificationType.commentReplied);
ScoreAdded.fromJson(scoreLike, NotificationType.scoreAdded);
ScoreSubstractedNotification.fromJson(scoreLike, NotificationType.scoreSubstracted);
EntityCommented.fromJson(entityCommented, NotificationType.entityCommented);
UnknownNotification.fromJson(base, NotificationType.unknown);
ConsultantConfirmPlanSubscriptionToSubscriberNotification.fromJson(
base,
NotificationType.consultantConfirmPlanSubscriptionToSubscriber,
);
ClientPaidPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.clientPaidPlanSubscriptionToAuthor);
ConsultantLeftDiaryCommentNotification.fromJson(base, NotificationType.consultantLeftDiaryComment);
ConsultantConfirmPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.consultantConfirmPlanSubscriptionToAuthor);
ClientPaidPlanSubscriptionToSubscriberNotification.fromJson(base, NotificationType.clientPaidPlanSubscriptionToSubscriber);
ClientStartPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.clientStartPlanSubscriptionToAuthor);
ClientCancelPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.clientCancelPlanSubscriptionToAuthor);
ClientCancelPlanSubscriptionToSubscriberNotification.fromJson(base, NotificationType.clientCancelPlanSubscriptionToSubscriber);
UserDailyReportCompletedNotification.fromJson(base, NotificationType.userDailyReportCompleted);
StayOnTrackToCustomerNotification.fromJson(base, NotificationType.stayOnTrackToCustomer);
PhoneChangedNotification.fromJson(base, NotificationType.phoneChanged);
SubscriptionNotification.fromJson(base, NotificationType.subscription);
BadgeAwardedNotification.fromJson(base, NotificationType.badgeAwarded);
BadgeRevokedNotification.fromJson(base, NotificationType.badgeRevoked);
ConsultantRequestStartedNotification.fromJson(base, NotificationType.consultantRequestStarted);
ClientCloseComplaintSuccessNotification.fromJson(base, NotificationType.clientCloseComplaintSuccess);
ClientRefundSuccessNotification.fromJson(base, NotificationType.clientRefundSuccess);
ConsultantRefundSuccessNotification.fromJson(base, NotificationType.consultantRefundSuccess);
ClientRejectSuccessNotification.fromJson(base, NotificationType.clientRejectSuccess);
ConsultantRejectSuccessNotification.fromJson(base, NotificationType.consultantRejectSuccess);
ProductStatusUpdatedForAuthorNotification.fromJson(base, NotificationType.productStatusUpdatedForAuthor);
DiaryReminderForUsersNotification.fromJson(base, NotificationType.diaryReminderForUsers);
DiaryReminderForUsersNotification.fromJson({'created_at': c, 'data': <String, dynamic>{}}, NotificationType.diaryReminderForUsers);
PersonalDietIsDefinedToCustomerNotification.fromJson(base, NotificationType.personalDietIsDefinedToCustomer);
ConsultantEducationUpdatedNotification.fromJson(base, NotificationType.consultantEducationUpdated);
ComplaintRegisteredNotification.fromJson(base, NotificationType.complaintRegistered);
ContentUnpublishedNotification.fromJson(base, NotificationType.contentUnpublished);
FinanceQuestionCreatedNotification.fromJson(base, NotificationType.financeQuestionCreated);
NewChatMessageNotification.fromJson(base, NotificationType.newChatMessage);
CompleteRegistrationNotification.fromJson(base, NotificationType.completeRegistration);
ChosenDietExpiredNotification.fromJson(base, NotificationType.chosenDietExpired);
MotivateUserNotification.fromJson(base, NotificationType.motivateUser);
CommentComplaintNotification.fromJson(base, NotificationType.commentComplaint);
CommentDeletedNotification.fromJson(base, NotificationType.commentDeleted);
UserSubscribedNotification.fromJson(base, NotificationType.userSubscribed);
});
test('executes additional factories used by legacy and plan flows', () {
ClientStartPlanSubscriptionToSubscriberNotification.fromJson(base, NotificationType.clientStartPlanSubscriptionToSubscriber);
ChosenPlanFinishedToClientNotification.fromJson(base, NotificationType.chosenPlanFinishedToClient);
PlanSubscriptionCanceledToCustomerNotification.fromJson(base, NotificationType.planSubscriptionCanceledToCustomer);
PlanSubscriptionCanceledToAuthorNotification.fromJson(planAndUser, NotificationType.planSubscriptionCanceledToAuthor);
ChosenPlanStartedToClientNotification.fromJson(planAndUser, NotificationType.chosenPlanStartedToClient);
ChosenPlanTomorrowReminderToClientNotification.fromJson(planAndUser, NotificationType.chosenPlanTomorrowReminderToClient);
DiaryReminderNotification.fromJson(base, NotificationType.diaryReminderNotification);
ConsultantLeftCommentNotification.fromJson(base, NotificationType.consultantLeftComment);
PersonalizedDietIsDefinedNotification.fromJson(base, NotificationType.personalizedDietIsDefined);
PlanSubscriptionStartedToAuthorNotification.fromJson(planAndUser, NotificationType.planSubscriptionStartedToAuthor);
});
});
}

View File

@@ -71,7 +71,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
expect(aboutPlanCubit!.state.planModel, isNotNull);
expect(aboutPlanCubit!.state.planModel!.id, 1);
@@ -157,7 +157,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {});
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.likePlan(index: null, slug: 'test-plan');
await Future.delayed(const Duration(milliseconds: 1200));
@@ -170,7 +170,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenThrow(Exception('Ошибка загрузки'));
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
expect(aboutPlanCubit!.state.error, isNotNull);
expect(aboutPlanCubit!.state.isLoading, isFalse);
@@ -220,7 +220,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {});
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.likePlan(index: null, slug: 'test-plan');
expect(aboutPlanCubit!.state.planModel!.wasLikedByMe, isFalse);
@@ -263,7 +263,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.addToFavorite('test-plan')).thenAnswer((_) async => Future<void>.value());
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: null);
expect(aboutPlanCubit!.state.planModel!.isFavorite, true);
@@ -305,7 +305,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.removeFromFavorite('test-plan')).thenAnswer((_) async => Future<void>.value());
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: null);
expect(aboutPlanCubit!.state.planModel!.isFavorite, false);
@@ -357,7 +357,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.chosePlan('test-plan', any)).thenAnswer((_) async => chosenPlan);
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
aboutPlanCubit!.choseOption(0);
final result = await aboutPlanCubit!.chosePlan();
@@ -405,7 +405,7 @@ void main() {
),
);
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
aboutPlanCubit!.choseOption(0);
final result = await aboutPlanCubit!.chosePlan();
@@ -447,7 +447,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {});
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.likePlan(index: 5, slug: 'test-plan');
expect(aboutPlanCubit!.state.planModel!.wasLikedByMe, isTrue);
@@ -490,7 +490,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {});
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.likePlan(index: 5, slug: 'test-plan');
expect(aboutPlanCubit!.state.planModel!.wasLikedByMe, isFalse);
@@ -533,7 +533,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.likePlan('test-plan')).thenThrow(Exception('Ошибка'));
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.likePlan(index: 5, slug: 'test-plan');
// Проверяем, что состояние изменилось сразу после вызова
@@ -583,7 +583,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.addToFavorite('test-plan')).thenAnswer((_) async => Future<void>.value());
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: 5);
expect(aboutPlanCubit!.state.planModel!.isFavorite, true);
@@ -624,7 +624,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.addToFavorite('test-plan')).thenThrow(Exception('Ошибка'));
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: 5);
// Проверяем, что состояние изменилось сразу после вызова
@@ -673,7 +673,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.removeFromFavorite('test-plan')).thenAnswer((_) async => Future<void>.value());
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: 5);
expect(aboutPlanCubit!.state.planModel!.isFavorite, false);
@@ -714,7 +714,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.removeFromFavorite('test-plan')).thenThrow(Exception('Ошибка'));
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: 5);
// Проверяем, что состояние изменилось сразу после вызова
@@ -773,7 +773,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.chosePlan('test-plan', any)).thenAnswer((_) async => chosenPlan);
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
aboutPlanCubit!.choseOption(1);
final result = await aboutPlanCubit!.chosePlan();
@@ -826,7 +826,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.chosePlan('test-plan', any)).thenAnswer((_) async => chosenPlan);
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
aboutPlanCubit!.choseOption(2);
final result = await aboutPlanCubit!.chosePlan();
@@ -884,7 +884,7 @@ void main() {
),
);
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
aboutPlanCubit!.choseOption(0);
final result = await aboutPlanCubit!.chosePlan();
@@ -926,7 +926,7 @@ void main() {
when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan);
when(mockPlanRepository.chosePlan('test-plan', any)).thenThrow(Exception('Общая ошибка'));
await aboutPlanCubit!.getPlan('test-plan');
await aboutPlanCubit!.getPlan(slug: 'test-plan');
aboutPlanCubit!.choseOption(0);
final result = await aboutPlanCubit!.chosePlan();

View File

@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bookmark_button.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
@@ -167,7 +168,7 @@ void main() {
when(planCubit.state).thenReturn(state);
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
when(planCubit.getPlan(any)).thenAnswer((_) async => Future<void>.value());
when(planCubit.getPlan(slug: anyNamed('slug'), index: anyNamed('index'))).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
@@ -183,6 +184,95 @@ void main() {
expect(find.byType(AboutPlanScreen), findsOneWidget);
});
testWidgets('error state try again calls getPlan', (tester) async {
final planCubit = MockAboutPlanCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
const state = AboutPlanState(isLoading: false, error: 'Error loading plan', planModel: testPlan, chosenOptionIndex: 0);
when(planCubit.state).thenReturn(state);
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
when(planCubit.getPlan(slug: anyNamed('slug'), index: anyNamed('index'))).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
registerMocks(planCubit, commentsCubit);
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
await tester.pump();
await tester.tap(find.text('Попробовать снова'));
await tester.pump();
verify(planCubit.getPlan(slug: 'test', index: null)).called(1);
});
testWidgets('loading state back button does not throw', (tester) async {
final planCubit = MockAboutPlanCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
const state = AboutPlanState(isLoading: true, error: null, planModel: null, chosenOptionIndex: 0);
when(planCubit.state).thenReturn(state);
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
registerMocks(planCubit, commentsCubit);
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
await tester.pump();
await tester.tap(find.byType(BackButton));
await tester.pump();
expect(find.byType(AboutPlanScreen), findsOneWidget);
});
testWidgets('loaded state bookmark tap calls addToFavorite when not favorite', (tester) async {
final planCubit = MockAboutPlanCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null);
when(planCubit.state).thenReturn(state);
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
when(planCubit.addToFavorite(slug: anyNamed('slug'), index: anyNamed('index'))).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
registerMocks(planCubit, commentsCubit);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test-plan', navigateToComments: false, index: 7, highlightedCommentId: null)));
await tester.pump();
await tester.pump();
});
await tester.tap(find.byType(BookmarkButton));
await tester.pump();
verify(planCubit.addToFavorite(slug: 'test-plan', index: 7)).called(1);
});
testWidgets('renders with navigateToComments true', (tester) async {
final planCubit = MockAboutPlanCubit();
final feedCubit = MockFeedCubit();

View File

@@ -194,6 +194,63 @@ void main() {
expect(find.byType(AboutRecipeScreen), findsOneWidget);
});
testWidgets('error state try again calls getRecipe', (tester) async {
final recipeCubit = MockAboutRecipeCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
const state = AboutRecipeState.error(message: 'Error loading recipe');
when(recipeCubit.state).thenReturn(state);
when(recipeCubit.stream).thenAnswer((_) => const Stream<AboutRecipeState>.empty());
when(recipeCubit.close()).thenAnswer((_) async => Future<void>.value());
when(recipeCubit.getRecipe(any, index: anyNamed('index'))).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewRecipe(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
registerMocks(recipeCubit, commentsCubit);
await tester.pumpWidget(wrap(recipeCubit, feedCubit, commentsCubit, const AboutRecipeScreen(recipeSlug: 'test', index: 3, navigateToComments: false)));
await tester.pump();
await tester.tap(find.text('Попробовать снова'));
await tester.pump();
verify(recipeCubit.getRecipe('test', index: 3)).called(1);
});
testWidgets('loading state back button does not throw', (tester) async {
final recipeCubit = MockAboutRecipeCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
const state = AboutRecipeState.loading();
when(recipeCubit.state).thenReturn(state);
when(recipeCubit.stream).thenAnswer((_) => const Stream<AboutRecipeState>.empty());
when(recipeCubit.close()).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewRecipe(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
registerMocks(recipeCubit, commentsCubit);
await tester.pumpWidget(wrap(recipeCubit, feedCubit, commentsCubit, const AboutRecipeScreen(recipeSlug: 'test', index: null, navigateToComments: false)));
await tester.pump();
await tester.tap(find.byType(BackButton));
await tester.pump();
expect(find.byType(AboutRecipeScreen), findsOneWidget);
});
testWidgets('renders with navigateToComments true', (tester) async {
final recipeCubit = MockAboutRecipeCubit();
final feedCubit = MockFeedCubit();