feat(diary): tests

This commit is contained in:
mso7nueh
2026-05-04 17:36:48 +03:00
parent ea107ff07e
commit 91c8300da7
13 changed files with 2067 additions and 536 deletions

View File

@@ -0,0 +1,206 @@
import 'dart:async';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart';
import 'package:eatvio_app/core/local_db/services/glossary_sync_runner.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
@GenerateNiceMocks([
MockSpec<GlossarySyncService>(),
MockSpec<InternetConnectionCubit>(),
])
import 'glossary_sync_cubit_test.mocks.dart';
void main() {
late MockGlossarySyncService mockSyncService;
late MockInternetConnectionCubit mockConnectionCubit;
setUp(() {
mockSyncService = MockGlossarySyncService();
mockConnectionCubit = MockInternetConnectionCubit();
when(mockConnectionCubit.stream).thenAnswer((_) => const Stream.empty());
when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: false));
});
GlossarySyncCubit buildCubit() => GlossarySyncCubit(
syncService: mockSyncService,
connectionCubit: mockConnectionCubit,
);
group('GlossarySyncCubit', () {
test('initial state is idle', () {
final cubit = buildCubit();
expect(cubit.state, const GlossarySyncState.idle());
cubit.close();
});
test('syncNow emits completed with count when sync returns > 0', () async {
when(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).thenAnswer((_) async => 100);
final cubit = buildCubit();
final states = <GlossarySyncState>[];
final sub = cubit.stream.listen(states.add);
await cubit.syncNow();
await Future.delayed(const Duration(milliseconds: 50));
expect(states.last, isA<GlossarySyncState>());
await sub.cancel();
await cubit.close();
});
test('syncNow emits upToDate when sync returns 0', () async {
when(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).thenAnswer((_) async => 0);
final cubit = buildCubit();
final states = <GlossarySyncState>[];
final sub = cubit.stream.listen(states.add);
await cubit.syncNow();
await Future.delayed(const Duration(milliseconds: 50));
expect(states.last, const GlossarySyncState.upToDate());
await sub.cancel();
await cubit.close();
});
test('syncNow emits error on exception', () async {
when(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).thenThrow(Exception('Test error'));
final cubit = buildCubit();
final states = <GlossarySyncState>[];
final sub = cubit.stream.listen(states.add);
await cubit.syncNow();
await Future.delayed(const Duration(milliseconds: 50));
expect(states.isNotEmpty, true);
await sub.cancel();
await cubit.close();
});
test('syncNow emits error with socket message', () async {
when(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).thenThrow(Exception('SocketException: connection refused'));
final cubit = buildCubit();
final states = <GlossarySyncState>[];
final sub = cubit.stream.listen(states.add);
await cubit.syncNow();
await Future.delayed(const Duration(milliseconds: 50));
expect(states.isNotEmpty, true);
await sub.cancel();
await cubit.close();
});
test('syncNow emits error with timeout message', () async {
when(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).thenThrow(TimeoutException('timeout'));
final cubit = buildCubit();
final states = <GlossarySyncState>[];
final sub = cubit.stream.listen(states.add);
await cubit.syncNow();
await Future.delayed(const Duration(milliseconds: 50));
expect(states.isNotEmpty, true);
await sub.cancel();
await cubit.close();
});
test('start() listens to connectionCubit', () {
final cubit = buildCubit();
cubit.start();
verify(mockConnectionCubit.stream).called(1);
cubit.close();
});
test('duplicate syncNow calls are ignored', () async {
final completer = Completer<int>();
when(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).thenAnswer((_) => completer.future);
final cubit = buildCubit();
cubit.syncNow();
cubit.syncNow(); // Should be ignored
completer.complete(5);
await Future.delayed(const Duration(milliseconds: 100));
verify(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).called(1);
await cubit.close();
});
test('stage callbacks emit correct states', () async {
when(mockSyncService.sync(
onStageChanged: anyNamed('onStageChanged'),
onDownloadProgress: anyNamed('onDownloadProgress'),
onProcessingProgress: anyNamed('onProcessingProgress'),
)).thenAnswer((invocation) async {
final stageCallback = invocation.namedArguments[#onStageChanged] as Function(GlossarySyncStage);
final downloadCallback = invocation.namedArguments[#onDownloadProgress] as Function(double);
final processingCallback = invocation.namedArguments[#onProcessingProgress] as Function(int, int);
stageCallback(GlossarySyncStage.checking);
stageCallback(GlossarySyncStage.downloading);
downloadCallback(0.5);
stageCallback(GlossarySyncStage.processing);
processingCallback(50, 100);
stageCallback(GlossarySyncStage.saving);
return 100;
});
final cubit = buildCubit();
final states = <GlossarySyncState>[];
final sub = cubit.stream.listen(states.add);
await cubit.syncNow();
await Future.delayed(const Duration(milliseconds: 100));
expect(states.length, greaterThanOrEqualTo(4));
await sub.cancel();
await cubit.close();
});
test('close cancels subscriptions', () async {
final cubit = buildCubit();
cubit.start();
await cubit.close();
// Should not crash
});
});
}

View File

@@ -0,0 +1,97 @@
import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_state.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/glossary_sync_banner_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
@GenerateNiceMocks([MockSpec<GlossarySyncCubit>()])
import 'glossary_sync_banner_widget_test.mocks.dart';
void main() {
late MockGlossarySyncCubit mockCubit;
setUp(() {
mockCubit = MockGlossarySyncCubit();
when(mockCubit.stream).thenAnswer((_) => const Stream<GlossarySyncState>.empty());
when(mockCubit.close()).thenAnswer((_) async {});
});
Widget buildWidget() {
return MaterialApp(
home: BlocProvider<GlossarySyncCubit>.value(
value: mockCubit,
child: const Scaffold(
body: GlossarySyncBannerWidget(),
),
),
);
}
group('GlossarySyncBannerWidget', () {
testWidgets('idle state shows nothing', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.idle());
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.byType(GlossarySyncBannerWidget), findsOneWidget);
});
testWidgets('upToDate state shows nothing', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.upToDate());
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.byType(GlossarySyncBannerWidget), findsOneWidget);
});
testWidgets('checking state shows checking text', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.checking());
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Проверяю'), findsOneWidget);
});
testWidgets('downloading with progress shows percentage', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.downloading(progress: 0.45));
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('45%'), findsOneWidget);
});
testWidgets('downloading without progress shows dots', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.downloading());
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Загрузка'), findsOneWidget);
});
testWidgets('processing shows progress', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.processing(processed: 12500, total: 200000));
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Обработка'), findsOneWidget);
});
testWidgets('processing with 0 total shows generic text', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.processing(processed: 0, total: 0));
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Обработка данных'), findsOneWidget);
});
testWidgets('completed shows count', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.completed(syncedCount: 200000));
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Синхронизировано'), findsOneWidget);
});
testWidgets('error shows error message', (tester) async {
when(mockCubit.state).thenReturn(const GlossarySyncState.error(message: 'Ошибка синхронизации'));
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Ошибка'), findsOneWidget);
});
});
}

View File

@@ -0,0 +1,124 @@
import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/offline_banner_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
@GenerateNiceMocks([MockSpec<DiaryCubit>()])
import 'offline_banner_widget_test.mocks.dart';
DiaryDayEntity _makeDiary() => DiaryDayEntity(
id: 1,
date: '2025-01-01',
wakeup: '06:30:00',
retire: '22:30:00',
closed: 0,
comment: '',
typeOfDiary: const TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null),
meals: [
const MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [], photos: []),
],
chosenPlanId: null,
dayOfChosenDiet: null,
diet: null,
);
void main() {
late MockDiaryCubit mockDiaryCubit;
setUp(() {
mockDiaryCubit = MockDiaryCubit();
when(mockDiaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(mockDiaryCubit.close()).thenAnswer((_) async {});
});
Widget buildWidget() {
return MaterialApp(
home: BlocProvider<DiaryCubit>.value(
value: mockDiaryCubit,
child: const Scaffold(
body: OfflineBannerWidget(),
),
),
);
}
group('OfflineBannerWidget', () {
testWidgets('shows nothing when online and no pending ops', (tester) async {
when(mockDiaryCubit.state).thenReturn(
DiaryState.loaded(
diary: _makeDiary(),
selectedDay: DateTime(2025, 1, 1),
selectedMeal: 1,
isOfflineMode: false,
pendingOpsCount: 0,
),
);
await tester.pumpWidget(buildWidget());
await tester.pump();
// SizedBox.shrink should be rendered — no banner text visible
expect(find.textContaining('Офлайн'), findsNothing);
expect(find.textContaining('Синхронизация'), findsNothing);
});
testWidgets('shows offline banner with pending ops', (tester) async {
when(mockDiaryCubit.state).thenReturn(
DiaryState.loaded(
diary: _makeDiary(),
selectedDay: DateTime(2025, 1, 1),
selectedMeal: 1,
isOfflineMode: true,
pendingOpsCount: 3,
),
);
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Офлайн'), findsOneWidget);
expect(find.textContaining('3'), findsWidgets);
});
testWidgets('shows offline without pending ops', (tester) async {
when(mockDiaryCubit.state).thenReturn(
DiaryState.loaded(
diary: _makeDiary(),
selectedDay: DateTime(2025, 1, 1),
selectedMeal: 1,
isOfflineMode: true,
pendingOpsCount: 0,
),
);
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('кэша'), findsOneWidget);
});
testWidgets('shows sync banner when online with pending ops', (tester) async {
when(mockDiaryCubit.state).thenReturn(
DiaryState.loaded(
diary: _makeDiary(),
selectedDay: DateTime(2025, 1, 1),
selectedMeal: 1,
isOfflineMode: false,
pendingOpsCount: 5,
),
);
await tester.pumpWidget(buildWidget());
await tester.pump();
expect(find.textContaining('Синхронизация'), findsOneWidget);
});
testWidgets('hides when state is not loaded', (tester) async {
when(mockDiaryCubit.state).thenReturn(const DiaryState.loading());
await tester.pumpWidget(buildWidget());
await tester.pump();
// SizedBox.shrink should be rendered
expect(find.textContaining('Офлайн'), findsNothing);
expect(find.textContaining('Синхронизация'), findsNothing);
});
});
}

View File

@@ -1,93 +1,102 @@
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart';
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
import 'package:eatvio_app/features/glossary/models/glossary_model.dart';
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
import 'package:eatvio_app/features/glossary/services/glossary_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
class MockGlossaryService extends Mock implements GlossaryService {}
class MockInternetConnectionCubit extends Mock implements InternetConnectionCubit {}
class MockDiaryLocalDataSource extends Mock implements DiaryLocalDataSource {}
@GenerateNiceMocks([
MockSpec<GlossaryService>(),
MockSpec<DiaryLocalDataSource>(),
MockSpec<InternetConnectionCubit>(),
])
import 'glossary_repository_test.mocks.dart';
void main() {
late GlossaryRepository repository;
late MockGlossaryService mockService;
late MockDiaryLocalDataSource mockLocalDataSource;
late MockInternetConnectionCubit mockConnectionCubit;
setUp(() {
mockService = MockGlossaryService();
mockLocalDataSource = MockDiaryLocalDataSource();
mockConnectionCubit = MockInternetConnectionCubit();
repository = GlossaryRepository(
glossaryService: mockService,
localDataSource: mockLocalDataSource,
connectionCubit: mockConnectionCubit,
);
});
group('GlossaryRepository', () {
late GlossaryRepository repository;
late MockGlossaryService mockService;
late MockInternetConnectionCubit mockConnectionCubit;
late MockDiaryLocalDataSource mockLocalDataSource;
test('offline → searches local cache', () async {
when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: false));
when(mockLocalDataSource.searchGlossary(any)).thenAnswer((_) async => [
const GlossaryCacheTableData(
id: 1,
name: 'Яблоко',
productGroupId: 5,
energyOn100gr: 52.0,
proteinsOn100gr: 0.3,
fatsOn100gr: 0.2,
carbohydratesOn100gr: 14.0,
portionMassGr: 150.0,
energyOnPortion: 78.0,
proteinsOnPortion: 0.45,
fatsOnPortion: 0.3,
carbohydratesOnPortion: 21.0,
updatedAt: '2024-01-01',
),
]);
setUp(() {
mockService = MockGlossaryService();
mockConnectionCubit = MockInternetConnectionCubit();
mockLocalDataSource = MockDiaryLocalDataSource();
when(() => mockConnectionCubit.state).thenReturn(
InternetConnectionState(isInternetAvailable: true),
);
repository = GlossaryRepository(
glossaryService: mockService,
localDataSource: mockLocalDataSource,
connectionCubit: mockConnectionCubit,
);
final result = await repository.getGlossaryListByQuery('яблоко');
expect(result.length, 1);
expect(result[0].name, 'Яблоко');
expect(result[0].energyOn100Gr, 52.0);
expect(result[0].grInPortion, 150);
verifyNever(mockService.getGlossaryByQuery(query: anyNamed('query')));
});
test('getGlossaryListByQuery - успешный запрос', () async {
const query = 'test';
final mockGlossaries = <GlossaryModel>[];
final mockPagination = GlossaryPaginationModel(glossariesList: mockGlossaries);
test('online → uses network service', () async {
when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true));
when(mockService.getGlossaryByQuery(query: anyNamed('query'))).thenAnswer(
(_) async => const GlossaryPaginationModel(
glossariesList: [
GlossaryModel(
id: 1,
name: 'Яблоко',
grInPortion: 150,
energyOn100Gr: 52.0,
proteinsOn100Gr: 0.3,
fatsOn100Gr: 0.2,
carbohydratesOn100Gr: 14.0,
energyOnPortion: 78.0,
proteinsOnPortion: 0.45,
fatsOnPortion: 0.3,
carbohydratesOnPortion: 21.0,
),
],
),
);
when(() => mockService.getGlossaryByQuery(query: query))
.thenAnswer((_) async => mockPagination);
final result = await repository.getGlossaryListByQuery(query);
expect(result, mockGlossaries);
verify(() => mockService.getGlossaryByQuery(query: query)).called(1);
final result = await repository.getGlossaryListByQuery('яблоко');
expect(result.length, 1);
expect(result[0].name, 'Яблоко');
});
test('getGlossaryListByQuery - пробрасывает ошибку', () async {
const query = 'test';
test('online with network error → falls back to local cache', () async {
when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true));
when(mockService.getGlossaryByQuery(query: anyNamed('query'))).thenThrow(Exception('Network error'));
when(mockLocalDataSource.searchGlossary(any)).thenAnswer((_) async => []);
when(() => mockService.getGlossaryByQuery(query: query))
.thenThrow(Exception('Error'));
when(() => mockLocalDataSource.searchGlossary(query))
.thenThrow(Exception('Local error'));
expect(() => repository.getGlossaryListByQuery(query), throwsException);
final result = await repository.getGlossaryListByQuery('яблоко');
expect(result, isEmpty);
verify(mockLocalDataSource.searchGlossary('яблоко')).called(1);
});
});
}

View File

@@ -1,228 +1,176 @@
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('MyRecipeModel', () {
const model = MyRecipeModel(
id: 1,
image: 'https://example.com/img.jpg',
state: 'active',
cookingTime: 30,
activeCookingTime: 15,
description: '# Recipe\nDelicious',
steps: [Step(image: 'step.jpg', description: 'Step 1')],
videoUrl: 'https://youtube.com/watch?v=123',
title: 'Test Recipe',
slug: 'test-recipe',
calories: 250.5,
likesCount: 10,
viewsCount: 100,
);
test('fields are accessible', () {
expect(model.id, 1);
expect(model.image, 'https://example.com/img.jpg');
expect(model.state, 'active');
expect(model.cookingTime, 30);
expect(model.activeCookingTime, 15);
expect(model.description, '# Recipe\nDelicious');
expect(model.videoUrl, 'https://youtube.com/watch?v=123');
expect(model.title, 'Test Recipe');
expect(model.slug, 'test-recipe');
expect(model.calories, 250.5);
expect(model.likesCount, 10);
expect(model.viewsCount, 100);
expect(model.steps?.length, 1);
});
test('fromJson parses correctly', () {
final json = {
'id': 2,
'image': 'img.jpg',
'state': 'draft',
'cooking_time': 60,
'active_time': 20,
'description_md': 'Desc',
'steps': [{'image': 's.jpg', 'description_md': 'S1'}],
'video_url': null,
'title': 'Title',
'slug': 'slug',
'energy_per_serving': 300.0,
'total_likes': 5,
'total_views': 50,
};
final parsed = MyRecipeModel.fromJson(json);
expect(parsed.id, 2);
expect(parsed.cookingTime, 60);
expect(parsed.calories, 300.0);
});
test('toRecipeModel converts correctly', () {
final recipeModel = model.toRecipeModel();
expect(recipeModel.title, 'Test Recipe');
expect(recipeModel.description, '# Recipe\nDelicious');
expect(recipeModel.totalCookingTime, 30);
expect(recipeModel.activeCookTime, 15);
expect(recipeModel.energyPerServing, 250);
expect(recipeModel.state, 'active');
});
test('toRecipe converts correctly', () {
final recipe = model.toRecipe();
expect(recipe.id, 1);
expect(recipe.image, 'https://example.com/img.jpg');
expect(recipe.state, 'active');
expect(recipe.cookingTime, 30);
expect(recipe.slug, 'test-recipe');
expect(recipe.calories, 250.5);
expect(recipe.likesCount, 10);
expect(recipe.type, 'recipe');
expect(recipe.commentsCount, 0);
expect(recipe.wasLikedByMe, false);
});
test('toRecipeModel with null steps', () {
const noSteps = MyRecipeModel(
id: 1, image: '', state: 'active', cookingTime: 10,
activeCookingTime: 5, description: '', steps: null,
videoUrl: null, title: null, slug: 'slug', calories: 0,
likesCount: 0, viewsCount: 0,
);
final rm = noSteps.toRecipeModel();
expect(rm.steps, isNull);
});
});
group('MyRecipePaginationModel', () {
test('creates MyRecipePaginationModel with empty list', () {
const model = MyRecipePaginationModel(data: []);
expect(model.data, isEmpty);
});
test('creates MyRecipePaginationModel with recipes', () {
final recipes = [
const MyRecipeModel(
id: 1,
image: 'image1.jpg',
state: 'published',
cookingTime: 30,
activeCookingTime: 15,
description: 'Test recipe 1',
steps: null,
videoUrl: null,
title: 'Recipe 1',
slug: 'recipe-1',
calories: 250.0,
likesCount: 10,
viewsCount: 100,
),
const MyRecipeModel(
id: 2,
image: 'image2.jpg',
state: 'draft',
cookingTime: 45,
activeCookingTime: 20,
description: 'Test recipe 2',
steps: null,
videoUrl: 'video.mp4',
title: 'Recipe 2',
slug: 'recipe-2',
calories: 300.0,
likesCount: 5,
viewsCount: 50,
),
];
final model = MyRecipePaginationModel(data: recipes);
expect(model.data.length, 2);
expect(model.data[0].id, 1);
expect(model.data[0].title, 'Recipe 1');
expect(model.data[1].id, 2);
expect(model.data[1].title, 'Recipe 2');
});
test('creates MyRecipePaginationModel from JSON', () {
test('fromJson parses correctly', () {
final json = {
'data': [
{
'id': 1,
'image': 'image1.jpg',
'state': 'published',
'image': 'img.jpg',
'state': 'active',
'cooking_time': 30,
'active_time': 15,
'description_md': 'Test recipe',
'description_md': 'Desc',
'steps': null,
'video_url': null,
'title': 'Recipe 1',
'slug': 'recipe-1',
'energy_per_serving': 250.0,
'total_likes': 10,
'total_views': 100,
'title': 'Title',
'slug': 'slug',
'energy_per_serving': 100.0,
'total_likes': 0,
'total_views': 0,
},
],
};
final model = MyRecipePaginationModel.fromJson(json);
expect(model.data.length, 1);
expect(model.data[0].id, 1);
expect(model.data[0].title, 'Recipe 1');
final parsed = MyRecipePaginationModel.fromJson(json);
expect(parsed.data.length, 1);
expect(parsed.data[0].title, 'Title');
});
});
group('MyRecipeModel', () {
test('creates MyRecipeModel with all parameters', () {
const recipe = MyRecipeModel(
group('RecipeToRecipeModelX', () {
test('Recipe.toRecipeModel converts correctly', () {
const recipe = Recipe(
id: 1,
image: 'image.jpg',
state: 'published',
cookingTime: 30,
activeCookingTime: 15,
description: 'Test recipe',
steps: null,
image: 'img.jpg',
state: 'active',
cookingTime: 45,
activeCookingTime: 20,
ingredients: [
Ingredient(name: 'Flour', portion: '200g', productId: 1),
],
description: 'Recipe description',
steps: [Step(image: 's.jpg', description: 'Step')],
videoUrl: null,
title: 'Recipe 1',
slug: 'recipe-1',
calories: 250.0,
likesCount: 10,
viewsCount: 100,
title: 'My Recipe',
type: 'recipe',
slug: 'my-recipe',
commentsCount: 3,
wasLikedByMe: true,
isFavorite: false,
calories: 350.0,
likesCount: 20,
viewsCount: 200,
tags: [Tag(name: 'healthy')],
author: Author(authorSlug: 'user1', authorName: 'User', authorProfileIcon: '', authorIsMe: true),
);
expect(recipe.id, 1);
expect(recipe.image, 'image.jpg');
expect(recipe.state, 'published');
expect(recipe.cookingTime, 30);
expect(recipe.activeCookingTime, 15);
expect(recipe.description, 'Test recipe');
expect(recipe.steps, null);
expect(recipe.videoUrl, null);
expect(recipe.title, 'Recipe 1');
expect(recipe.slug, 'recipe-1');
expect(recipe.calories, 250.0);
expect(recipe.likesCount, 10);
expect(recipe.viewsCount, 100);
final rm = recipe.toRecipeModel();
expect(rm.title, 'My Recipe');
expect(rm.totalCookingTime, 45);
expect(rm.activeCookTime, 20);
expect(rm.energyPerServing, 350);
expect(rm.state, 'active');
expect(rm.ingredients?.length, 1);
expect(rm.tags?.length, 1);
});
test('creates MyRecipeModel with steps and videoUrl', () {
final steps = [
const Step(image: 'step1.jpg', description: 'Step 1'),
const Step(image: 'step2.jpg', description: 'Step 2'),
];
final recipe = MyRecipeModel(
id: 1,
image: 'image.jpg',
state: 'published',
cookingTime: 30,
activeCookingTime: 15,
description: 'Test recipe',
steps: steps,
videoUrl: 'video.mp4',
title: 'Recipe 1',
slug: 'recipe-1',
calories: 250.0,
likesCount: 10,
viewsCount: 100,
test('Recipe.toRecipeModel with null ingredients', () {
const recipe = Recipe(
id: 1, image: '', state: 'active', cookingTime: 10,
activeCookingTime: 5, ingredients: null,
description: '', steps: null, videoUrl: null,
title: 'T', type: 'recipe', slug: 's',
commentsCount: 0, wasLikedByMe: false, isFavorite: false,
calories: 0, likesCount: 0, viewsCount: 0,
tags: [], author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false),
);
expect(recipe.steps, isNotNull);
expect(recipe.steps!.length, 2);
expect(recipe.videoUrl, 'video.mp4');
});
test('creates MyRecipeModel from JSON', () {
final json = {
'id': 1,
'image': 'image.jpg',
'state': 'published',
'cooking_time': 30,
'active_time': 15,
'description_md': 'Test recipe',
'steps': null,
'video_url': null,
'title': 'Recipe 1',
'slug': 'recipe-1',
'energy_per_serving': 250.0,
'total_likes': 10,
'total_views': 100,
};
final recipe = MyRecipeModel.fromJson(json);
expect(recipe.id, 1);
expect(recipe.title, 'Recipe 1');
expect(recipe.cookingTime, 30);
expect(recipe.calories, 250.0);
});
test('toJson serializes MyRecipeModel correctly', () {
const recipe = MyRecipeModel(
id: 1,
image: 'image.jpg',
state: 'published',
cookingTime: 30,
activeCookingTime: 15,
description: 'Test recipe',
steps: null,
videoUrl: null,
title: 'Recipe 1',
slug: 'recipe-1',
calories: 250.0,
likesCount: 10,
viewsCount: 100,
);
final json = recipe.toJson();
expect(json['id'], 1);
expect(json['title'], 'Recipe 1');
expect(json['slug'], 'recipe-1');
expect(json['cooking_time'], 30);
expect(json['active_time'], 15);
expect(json['energy_per_serving'], 250.0);
expect(json['total_likes'], 10);
expect(json['total_views'], 100);
});
});
group('MyRecipePaginationModel', () {
test('toJson serializes MyRecipePaginationModel correctly', () {
final recipes = [
const MyRecipeModel(
id: 1,
image: 'image1.jpg',
state: 'published',
cookingTime: 30,
activeCookingTime: 15,
description: 'Test recipe 1',
steps: null,
videoUrl: null,
title: 'Recipe 1',
slug: 'recipe-1',
calories: 250.0,
likesCount: 10,
viewsCount: 100,
),
];
final model = MyRecipePaginationModel(data: recipes);
final json = model.toJson();
expect(json.containsKey('data'), isTrue);
expect((json['data'] as List).length, 1);
final rm = recipe.toRecipeModel();
expect(rm.ingredients, isEmpty);
});
});
}

View File

@@ -0,0 +1,110 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/registration/registration_error_message.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('registrationErrorMessage', () {
test('returns string when error is String', () {
expect(registrationErrorMessage('Some error'), 'Some error');
});
test('returns message from 422 DioException with message field', () {
final error = DioException(
requestOptions: RequestOptions(path: ''),
response: Response(
requestOptions: RequestOptions(path: ''),
statusCode: 422,
data: {'message': 'Validation failed'},
),
);
expect(registrationErrorMessage(error), 'Validation failed');
});
test('returns errors from 422 DioException with errors field', () {
final error = DioException(
requestOptions: RequestOptions(path: ''),
response: Response(
requestOptions: RequestOptions(path: ''),
statusCode: 422,
data: {
'message': 'Validation error',
'errors': {
'email': ['Email уже используется'],
'name': ['Имя обязательно'],
},
},
),
);
final result = registrationErrorMessage(error);
expect(result, contains('Validation error'));
expect(result, contains('Email уже используется'));
expect(result, contains('Имя обязательно'));
});
test('returns data string for 422 with unknown format', () {
final error = DioException(
requestOptions: RequestOptions(path: ''),
response: Response(
requestOptions: RequestOptions(path: ''),
statusCode: 422,
data: {'unknown_key': 'value'},
),
);
expect(registrationErrorMessage(error), contains('Ошибка регистрации'));
});
test('returns DioException message for non-422 status', () {
final error = DioException(
requestOptions: RequestOptions(path: ''),
message: 'Connection timeout',
response: Response(
requestOptions: RequestOptions(path: ''),
statusCode: 500,
),
);
expect(registrationErrorMessage(error), 'Connection timeout');
});
test('returns DioException toString when message is null', () {
final error = DioException(
requestOptions: RequestOptions(path: ''),
);
expect(registrationErrorMessage(error), contains('DioException'));
});
test('returns toString for unknown error type', () {
expect(registrationErrorMessage(42), '42');
expect(registrationErrorMessage(Exception('Test')), contains('Test'));
});
test('handles 422 with empty errors list', () {
final error = DioException(
requestOptions: RequestOptions(path: ''),
response: Response(
requestOptions: RequestOptions(path: ''),
statusCode: 422,
data: {
'message': 'Error',
'errors': {
'field': <String>[],
},
},
),
);
expect(registrationErrorMessage(error), 'Error');
});
test('handles 422 with non-Map response data', () {
final error = DioException(
requestOptions: RequestOptions(path: ''),
response: Response(
requestOptions: RequestOptions(path: ''),
statusCode: 422,
data: 'plain string error',
),
);
// Not a map, falls through to DioException handling
expect(registrationErrorMessage(error), isNotEmpty);
});
});
}

View File

@@ -0,0 +1,221 @@
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/registration_v2/cubit/registration_state.dart';
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
@GenerateNiceMocks([MockSpec<RegistrationRepositoryV2>()])
import 'registration_cubit_test.mocks.dart';
void main() {
late RegistrationCubitV2 cubit;
late MockRegistrationRepositoryV2 mockRepo;
setUp(() {
mockRepo = MockRegistrationRepositoryV2();
cubit = RegistrationCubitV2(registrationRepository: mockRepo);
});
tearDown(() => cubit.close());
group('RegistrationCubitV2', () {
test('initial state', () {
expect(cubit.state, const RegistrationStateV2());
});
group('setters', () {
test('setFirstName updates firstName', () {
cubit.setFirstName('Иван');
expect(cubit.state.firstName, 'Иван');
});
test('setLastName updates lastName', () {
cubit.setLastName('Иванов');
expect(cubit.state.lastName, 'Иванов');
});
test('setEmail updates email', () {
cubit.setEmail('test@test.com');
expect(cubit.state.email, 'test@test.com');
});
test('setPhone updates phone', () {
cubit.setPhone('+79001234567');
expect(cubit.state.phone, '+79001234567');
});
test('setBirthday updates birthday', () {
final date = DateTime(1990, 1, 1);
cubit.setBirthday(date);
expect(cubit.state.birthday, date);
});
test('setGender updates gender', () {
cubit.setGender('male');
expect(cubit.state.gender, 'male');
});
test('setWeight updates weight', () {
cubit.setWeight(70);
expect(cubit.state.weight, 70);
});
test('setHeight updates height', () {
cubit.setHeight(175);
expect(cubit.state.height, 175);
});
test('setActivityCoefficient updates activityCoefficient', () {
cubit.setActivityCoefficient(1.2);
expect(cubit.state.activityCoefficient, 1.2);
});
test('setPassword updates password', () {
cubit.setPassword('secret123');
expect(cubit.state.password, 'secret123');
});
});
group('validators', () {
test('validateFirstName returns false for empty', () {
expect(cubit.validateFirstName(), false);
expect(cubit.state.isFirstNameValid, false);
});
test('validateFirstName returns true for non-empty', () {
cubit.setFirstName('Иван');
expect(cubit.validateFirstName(), true);
expect(cubit.state.isFirstNameValid, true);
});
test('validateLastName returns false for empty', () {
expect(cubit.validateLastName(), false);
expect(cubit.state.isLastNameValid, false);
});
test('validateLastName returns true for non-empty', () {
cubit.setLastName('Иванов');
expect(cubit.validateLastName(), true);
expect(cubit.state.isLastNameValid, true);
});
test('validateEmail returns false for empty', () {
expect(cubit.validateEmail(), false);
expect(cubit.state.isEmailValid, false);
});
test('validateEmail returns true for non-empty', () {
cubit.setEmail('test@test.com');
expect(cubit.validateEmail(), true);
expect(cubit.state.isEmailValid, true);
});
test('validatePhone returns false for empty', () {
expect(cubit.validatePhone(), false);
expect(cubit.state.isPhoneValid, false);
});
test('validatePhone returns true for non-empty', () {
cubit.setPhone('+79001234567');
expect(cubit.validatePhone(), true);
expect(cubit.state.isPhoneValid, true);
});
test('validateGender returns false for empty', () {
expect(cubit.validateGender(), false);
expect(cubit.state.isGenderValid, false);
});
test('validateGender returns true for non-empty', () {
cubit.setGender('male');
expect(cubit.validateGender(), true);
expect(cubit.state.isGenderValid, true);
});
test('validateWeight returns false for 0', () {
expect(cubit.validateWeight(), false);
expect(cubit.state.isWeightValid, false);
});
test('validateWeight returns true for non-zero', () {
cubit.setWeight(70);
expect(cubit.validateWeight(), true);
expect(cubit.state.isWeightValid, true);
});
test('validateHeight returns false for 0', () {
expect(cubit.validateHeight(), false);
expect(cubit.state.isHeightValid, false);
});
test('validateHeight returns true for non-zero', () {
cubit.setHeight(175);
expect(cubit.validateHeight(), true);
expect(cubit.state.isHeightValid, true);
});
test('validateActivityCoefficient returns false for 0', () {
// Set to 0 explicitly since default is 1.2
cubit.setActivityCoefficient(0);
expect(cubit.validateActivityCoefficient(), false);
expect(cubit.state.isActivityCoefficientValid, false);
});
test('validateActivityCoefficient returns true for non-zero', () {
cubit.setActivityCoefficient(1.5);
expect(cubit.validateActivityCoefficient(), true);
expect(cubit.state.isActivityCoefficientValid, true);
});
test('validateBirthday returns false for null', () {
expect(cubit.validateBirthday(), false);
expect(cubit.state.isBirthdayValid, false);
});
test('validateBirthday returns true for non-null', () {
cubit.setBirthday(DateTime(1990, 1, 1));
expect(cubit.validateBirthday(), true);
expect(cubit.state.isBirthdayValid, true);
});
});
group('navigation', () {
test('nextStep sets step to 1', () {
cubit.nextStep();
expect(cubit.state.step, 1);
});
test('prevStep sets step to 0', () {
cubit.nextStep();
cubit.prevStep();
expect(cubit.state.step, 0);
});
});
group('validateAllFields', () {
test('returns false when firstName empty', () {
expect(cubit.validateAllFields(), false);
});
test('returns true when all fields valid', () {
cubit.setFirstName('Иван');
cubit.setLastName('Иванов');
cubit.setEmail('test@test.com');
cubit.setPhone('+79001234567');
cubit.setGender('male');
cubit.setWeight(70);
cubit.setHeight(175);
cubit.setActivityCoefficient(1.2);
cubit.setBirthday(DateTime(1990, 1, 1));
expect(cubit.validateAllFields(), true);
});
});
group('register', () {
test('returns (false, true) when validation fails', () async {
final result = await cubit.register();
expect(result, (false, true));
});
});
});
}