forked from eatvio/eatvio_app
feat(insights): added kbju fetching for analitics
This commit is contained in:
@@ -31,24 +31,70 @@ void main() {
|
||||
};
|
||||
|
||||
test('parses commented_from_diet state', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'commented_from_diet'));
|
||||
final model = PortionModel.fromJson(
|
||||
portionJson(state: 'commented_from_diet'),
|
||||
);
|
||||
|
||||
expect(model.portionState, PortionState.commentedFromDiet);
|
||||
});
|
||||
|
||||
test('parses reviewed_from_diet state', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'reviewed_from_diet'));
|
||||
final model = PortionModel.fromJson(
|
||||
portionJson(state: 'reviewed_from_diet'),
|
||||
);
|
||||
|
||||
expect(model.portionState, PortionState.reviewedFromDiet);
|
||||
});
|
||||
|
||||
test('does not throw on unknown state from backend', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'some_future_state'));
|
||||
final model = PortionModel.fromJson(
|
||||
portionJson(state: 'some_future_state'),
|
||||
);
|
||||
|
||||
expect(model.portionState, PortionState.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
group('DiaryDayModel.user (владелец дневника)', () {
|
||||
Map<String, dynamic> diaryJson({Object? user}) => {
|
||||
'id': 1,
|
||||
'date': '2026-11-09',
|
||||
'closed': 0,
|
||||
'comment': '',
|
||||
'state': {
|
||||
'has_chosen_diet': false,
|
||||
'has_chosen_plan': false,
|
||||
'last_state': null,
|
||||
},
|
||||
'meals': <dynamic>[],
|
||||
'chosen_plan_id': null,
|
||||
'day_of_chosen_diet': null,
|
||||
'chosen_diet': null,
|
||||
'user': ?user,
|
||||
};
|
||||
|
||||
test('user.slug попадает в entity как ownerSlug', () {
|
||||
final model = DiaryDayModel.fromJson(
|
||||
diaryJson(user: {'id': 3577, 'slug': 'id-3577'}),
|
||||
);
|
||||
|
||||
expect(model.owner?.id, 3577);
|
||||
expect(model.toEntity()!.ownerSlug, 'id-3577');
|
||||
});
|
||||
|
||||
test('старый ответ без user: ownerSlug == null', () {
|
||||
expect(DiaryDayModel.fromJson(diaryJson()).toEntity()!.ownerSlug, isNull);
|
||||
});
|
||||
|
||||
test('user.slug == null (у владельца нет slug): ownerSlug == null', () {
|
||||
final model = DiaryDayModel.fromJson(
|
||||
diaryJson(user: {'id': 1, 'slug': null}),
|
||||
);
|
||||
|
||||
expect(model.toEntity()!.ownerSlug, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('DiaryDayWrapperModel', () {
|
||||
test('fromJson parses diary correctly', () {
|
||||
final json = {
|
||||
@@ -57,7 +103,11 @@ void main() {
|
||||
'date': '2024-01-15',
|
||||
'closed': 0,
|
||||
'comment': 'Test comment',
|
||||
'state': {'has_chosen_diet': false, 'has_chosen_plan': false, 'last_state': null},
|
||||
'state': {
|
||||
'has_chosen_diet': false,
|
||||
'has_chosen_plan': false,
|
||||
'last_state': null,
|
||||
},
|
||||
'meals': [],
|
||||
'chosen_plan_id': null,
|
||||
'day_of_chosen_diet': null,
|
||||
@@ -78,7 +128,11 @@ void main() {
|
||||
date: '2024-01-15',
|
||||
closed: 0,
|
||||
comment: 'Test comment',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null),
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: false,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [],
|
||||
chosenPlanId: null,
|
||||
dayOfChosenDiet: null,
|
||||
@@ -119,7 +173,12 @@ void main() {
|
||||
|
||||
group('DiaryPhotoModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {'id': 10, 'original_name': 'photo.jpg', 'path': '/path/to/photo.jpg', 'size': 1024};
|
||||
final json = {
|
||||
'id': 10,
|
||||
'original_name': 'photo.jpg',
|
||||
'path': '/path/to/photo.jpg',
|
||||
'size': 1024,
|
||||
};
|
||||
|
||||
final model = DiaryPhotoModel.fromJson(json);
|
||||
|
||||
@@ -130,7 +189,12 @@ void main() {
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = DiaryPhotoModel(id: 10, originalName: 'photo.jpg', path: '/path/to/photo.jpg', size: 1024);
|
||||
const model = DiaryPhotoModel(
|
||||
id: 10,
|
||||
originalName: 'photo.jpg',
|
||||
path: '/path/to/photo.jpg',
|
||||
size: 1024,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
@@ -201,7 +265,11 @@ void main() {
|
||||
|
||||
group('ProductInMeal', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {'product_name': 'Banana', 'portion': '1 piece', 'quantity': 2};
|
||||
final json = {
|
||||
'product_name': 'Banana',
|
||||
'portion': '1 piece',
|
||||
'quantity': 2,
|
||||
};
|
||||
|
||||
final model = ProductInMeal.fromJson(json);
|
||||
|
||||
@@ -211,7 +279,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = ProductInMeal(productName: 'Banana', portion: '1 piece', quantity: 2);
|
||||
const model = ProductInMeal(
|
||||
productName: 'Banana',
|
||||
portion: '1 piece',
|
||||
quantity: 2,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
@@ -228,7 +300,11 @@ void main() {
|
||||
date: '2024-01-15',
|
||||
closed: 0,
|
||||
comment: 'Test',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null),
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: false,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [],
|
||||
chosenPlanId: null,
|
||||
dayOfChosenDiet: null,
|
||||
@@ -248,7 +324,11 @@ void main() {
|
||||
retire: '22:00',
|
||||
closed: 0,
|
||||
comment: 'Test comment',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null),
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: false,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [],
|
||||
chosenPlanId: null,
|
||||
dayOfChosenDiet: null,
|
||||
@@ -272,7 +352,11 @@ void main() {
|
||||
date: '2024-01-15',
|
||||
closed: 0,
|
||||
comment: 'Test',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: true, hasChosenPlan: false, diaryState: null),
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: true,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [],
|
||||
chosenPlanId: null,
|
||||
dayOfChosenDiet: 5,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
@@ -70,11 +72,13 @@ void main() {
|
||||
bool isDiaryFinished = false,
|
||||
bool isDiaryCommented = false,
|
||||
int closed = 0,
|
||||
String? ownerSlug,
|
||||
String wakeup = '07:00',
|
||||
}) {
|
||||
final diary = DiaryDayEntity(
|
||||
id: 1,
|
||||
date: date,
|
||||
wakeup: '07:00',
|
||||
wakeup: wakeup,
|
||||
retire: '23:00',
|
||||
closed: closed,
|
||||
comment: '',
|
||||
@@ -97,6 +101,7 @@ void main() {
|
||||
chosenPlanId: hasChosenPlan ? 10 : null,
|
||||
dayOfChosenDiet: null,
|
||||
diet: null,
|
||||
ownerSlug: ownerSlug,
|
||||
);
|
||||
const nutrition = NutritionDataModel(
|
||||
dailyNutritionIntake: NutritionModel(
|
||||
@@ -391,12 +396,14 @@ void main() {
|
||||
WidgetTester tester, {
|
||||
required bool isConsultantView,
|
||||
String? ownerSlug,
|
||||
String? diaryOwnerSlug,
|
||||
Stream<DiaryState>? diaryStates,
|
||||
required FakeInsightsRepository insightsRepository,
|
||||
}) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
final state = loadedState(hasChosenPlan: false).copyWith(isConsultantView: isConsultantView);
|
||||
final state = loadedState(hasChosenPlan: false, ownerSlug: diaryOwnerSlug).copyWith(isConsultantView: isConsultantView);
|
||||
when(diaryCubit.state).thenReturn(state);
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.stream).thenAnswer((_) => diaryStates ?? const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId')),
|
||||
@@ -426,7 +433,7 @@ void main() {
|
||||
expect(find.byType(InsightsCard), findsOneWidget);
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, isNull);
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).canAdd, isTrue);
|
||||
expect(insightsRepository.insightsRequests.single.slug, isNull);
|
||||
expect(insightsRepository.serverRequests.single.slug, isNull);
|
||||
});
|
||||
|
||||
testWidgets('consultant view shows insights card of the client without add button', (tester) async {
|
||||
@@ -437,7 +444,76 @@ void main() {
|
||||
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||
expect(card.slug, 'client');
|
||||
expect(card.canAdd, isFalse);
|
||||
expect(insightsRepository.insightsRequests.single.slug, 'client');
|
||||
expect(insightsRepository.serverRequests.single.slug, 'client');
|
||||
});
|
||||
|
||||
testWidgets('consultant view takes the owner slug from the diary (user.slug)', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, diaryOwnerSlug: 'client-from-diary', insightsRepository: insightsRepository);
|
||||
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, 'client-from-diary');
|
||||
expect(insightsRepository.serverRequests.single.slug, 'client-from-diary');
|
||||
});
|
||||
|
||||
testWidgets('diary owner slug wins over the route parameter', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: true,
|
||||
ownerSlug: 'from-route',
|
||||
diaryOwnerSlug: 'from-diary',
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, 'from-diary');
|
||||
});
|
||||
|
||||
testWidgets('own diary refreshes insights when meals or wake-up time change', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: false, diaryStates: states.stream, insightsRepository: insightsRepository);
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
|
||||
states.add(loadedState(hasChosenPlan: false, wakeup: '06:30'));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(insightsRepository.serverRequests, hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('own diary does not refresh insights when nothing relevant changed', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: false, diaryStates: states.stream, insightsRepository: insightsRepository);
|
||||
|
||||
states.add(loadedState(hasChosenPlan: false));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('consultant view does not refresh insights on diary changes', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: true,
|
||||
ownerSlug: 'client',
|
||||
diaryStates: states.stream,
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
states.add(loadedState(hasChosenPlan: false, wakeup: '06:30').copyWith(isConsultantView: true));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('consultant view without owner slug hides insights card', (tester) async {
|
||||
@@ -446,7 +522,7 @@ void main() {
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, insightsRepository: insightsRepository);
|
||||
|
||||
expect(find.byType(InsightsCard), findsNothing);
|
||||
expect(insightsRepository.insightsRequests, isEmpty);
|
||||
expect(insightsRepository.serverRequests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('consultant view hides insights card on 403', (tester) async {
|
||||
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
expect(model.bmi.deltaText, contains('рост'));
|
||||
});
|
||||
|
||||
test('intra без wake/sleep и null в average/percent', () {
|
||||
test('пустой intra без wake/sleep; daily == null', () {
|
||||
final model = InsightsPayloadModel.fromJson(
|
||||
_fixture('insights_payload_minimal.json'),
|
||||
);
|
||||
@@ -57,8 +57,7 @@ void main() {
|
||||
expect(model.intra!.wake, isNull);
|
||||
expect(model.intra!.sleep, isNull);
|
||||
expect(model.intra!.bars, isEmpty);
|
||||
expect(model.daily!.average, isNull);
|
||||
expect(model.daily!.percent, isNull);
|
||||
expect(model.daily, isNull);
|
||||
expect(model.weight.last, isNull);
|
||||
});
|
||||
|
||||
|
||||
154
test/features/insights/models/insights_response_model_test.dart
Normal file
154
test/features/insights/models/insights_response_model_test.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/insights/models/insights_response_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Map<String, dynamic> _fixture(String name) =>
|
||||
jsonDecode(File('test/fixtures/insights/$name').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
/// Контракт `GET /api/profile/insights*` (eatvio/eatvio#3017).
|
||||
void main() {
|
||||
group('InsightsResponseModel', () {
|
||||
test('полный ответ: data с weight/bmi/measure/daily/intra', () {
|
||||
final data = InsightsResponseModel.fromJson(
|
||||
_fixture('insights_response.json'),
|
||||
).data;
|
||||
|
||||
expect(data.weight.headline, '73.2');
|
||||
expect(data.measure.waist, [82, 80.5]);
|
||||
expect(data.daily!.kcal, hasLength(3));
|
||||
expect(data.intra!.bars, hasLength(2));
|
||||
});
|
||||
|
||||
test('daily.percent — изменение за период, а не процент от нормы', () {
|
||||
final daily = InsightsResponseModel.fromJson(
|
||||
_fixture('insights_response.json'),
|
||||
).data.daily!;
|
||||
|
||||
expect(daily.percent, 5);
|
||||
expect(daily.deltaText, 'Калораж вырос на 5% за период');
|
||||
});
|
||||
|
||||
test('нет КБЖУ за период: daily == null', () {
|
||||
final data = InsightsResponseModel.fromJson(
|
||||
_fixture('insights_response_empty.json'),
|
||||
).data;
|
||||
|
||||
expect(data.daily, isNull);
|
||||
});
|
||||
|
||||
test('пустые замеры: headline "—" и текст «Недостаточно данных»', () {
|
||||
final measure = InsightsResponseModel.fromJson(
|
||||
_fixture('insights_response_empty.json'),
|
||||
).data.measure;
|
||||
|
||||
expect(measure.labels, isEmpty);
|
||||
expect(measure.headline, '—');
|
||||
expect(measure.deltaText, 'Недостаточно данных');
|
||||
});
|
||||
|
||||
test('пустой день: intra приходит объектом с датами, без сна', () {
|
||||
final intra = InsightsResponseModel.fromJson(
|
||||
_fixture('insights_response_empty.json'),
|
||||
).data.intra!;
|
||||
|
||||
expect(intra.bars, isEmpty);
|
||||
expect(intra.satiety, isEmpty);
|
||||
expect(intra.mood, isEmpty);
|
||||
expect(intra.wake, isNull);
|
||||
expect(intra.sleep, isNull);
|
||||
expect(intra.date, '2026-11-09');
|
||||
expect(intra.maxDate, '2026-11-09');
|
||||
expect(intra.isToday, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'сокращённый пример из описания бэка разбирается без подписей сна',
|
||||
() {
|
||||
final data = InsightsResponseModel.fromJson(
|
||||
jsonDecode(
|
||||
jsonEncode({
|
||||
'data': {
|
||||
'weight': {
|
||||
'labels': ['30.10', '07.11'],
|
||||
'values': [80, 78],
|
||||
'last': 78,
|
||||
'delta': -2,
|
||||
'headline': '78',
|
||||
'unit': 'кг',
|
||||
'deltaText': 'Снизили на 2 кг за 3 месяца',
|
||||
},
|
||||
'bmi': {},
|
||||
'measure': {
|
||||
'labels': [],
|
||||
'waist': [],
|
||||
'chest': [],
|
||||
'hips': [],
|
||||
'headline': '—',
|
||||
'deltaText': 'Недостаточно данных',
|
||||
},
|
||||
'daily': {
|
||||
'labels': ['09.11'],
|
||||
'kcal': [250],
|
||||
'proteins': [10],
|
||||
'fats': [5],
|
||||
'carbs': [30],
|
||||
'average': 250,
|
||||
'percent': 0,
|
||||
'headline': '250',
|
||||
'deltaText': 'Калораж не изменился за период',
|
||||
},
|
||||
'intra': {
|
||||
'bars': [
|
||||
{'x': 12.5, 'p': 40, 'f': 45, 'c': 120},
|
||||
],
|
||||
'satiety': [
|
||||
{'x': 12.5, 'y': 4},
|
||||
],
|
||||
'mood': [
|
||||
{'x': 12.5, 'y': 3},
|
||||
],
|
||||
'wake': 7,
|
||||
'sleep': 23,
|
||||
'date': '2026-11-09',
|
||||
'maxDate': '2026-11-09',
|
||||
'dateLabel': 'Сегодня',
|
||||
'isToday': true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
as Map<String, dynamic>,
|
||||
).data;
|
||||
|
||||
expect(data.weight.values, [80, 78]);
|
||||
expect(data.daily!.percent, 0);
|
||||
expect(data.intra!.wake, 7);
|
||||
expect(data.intra!.wakeLabel, isNull);
|
||||
expect(data.intra!.wakeText, isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('InsightsIntraResponseModel', () {
|
||||
test('ответ /insights/intra: data.intra', () {
|
||||
final intra = InsightsIntraResponseModel.fromJson(
|
||||
_fixture('insights_intra_response.json'),
|
||||
).data.intra;
|
||||
|
||||
expect(intra.date, '2026-11-09');
|
||||
expect(intra.bars, isNotEmpty);
|
||||
});
|
||||
|
||||
test('прошедший день: isToday == false', () {
|
||||
final intra = InsightsIntraResponseModel.fromJson(
|
||||
_fixture('insights_intra_response_yesterday.json'),
|
||||
).data.intra;
|
||||
|
||||
expect(intra.isToday, isFalse);
|
||||
expect(intra.dateLabel, '8 ноября');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||
|
||||
/// Серверный режим (`kbjuEnabled: true`): `GET /api/profile/insights*`.
|
||||
void main() {
|
||||
late FakeInsightsRepository repository;
|
||||
late IntraChartModel today;
|
||||
late IntraChartModel yesterday;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeInsightsRepository();
|
||||
today = repository.payload.intra!;
|
||||
yesterday = today.copyWith(
|
||||
date: '2026-11-08',
|
||||
dateLabel: '8 ноября',
|
||||
isToday: false,
|
||||
);
|
||||
repository.intraByDate['2026-11-08'] = yesterday;
|
||||
repository.intraByDate['2026-11-09'] = today;
|
||||
});
|
||||
|
||||
InsightsCubit buildCubit({String? slug}) {
|
||||
return InsightsCubit(repository: repository, slug: slug, kbjuEnabled: true);
|
||||
}
|
||||
|
||||
InsightsStateLoaded loaded(InsightsCubit cubit) =>
|
||||
cubit.state as InsightsStateLoaded;
|
||||
|
||||
group('load', () {
|
||||
test('запрашивает серверный payload с дефолтными периодами', () async {
|
||||
final cubit = buildCubit();
|
||||
|
||||
await cubit.load();
|
||||
|
||||
expect(repository.serverRequests, [
|
||||
(
|
||||
slug: null,
|
||||
bodyPeriod: BodyPeriod.threeMonths,
|
||||
dailyPeriod: DailyPeriod.twoWeeks,
|
||||
intraDate: null,
|
||||
),
|
||||
]);
|
||||
expect(repository.insightsRequests, isEmpty);
|
||||
expect(loaded(cubit).payload.daily, isNotNull);
|
||||
expect(loaded(cubit).dailyPeriod, DailyPeriod.twoWeeks);
|
||||
expect(loaded(cubit).intraDate, isNull);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('рост профиля не запрашивается: ИМТ считает сервер', () async {
|
||||
repository.heightCm = null;
|
||||
final cubit = buildCubit(slug: 'client');
|
||||
|
||||
await cubit.load();
|
||||
|
||||
expect(repository.serverRequests.single.slug, 'client');
|
||||
expect(loaded(cubit).payload.bmi, repository.payload.bmi);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('403 скрывает блок', () async {
|
||||
repository.error = const InsightsForbidden();
|
||||
final cubit = buildCubit(slug: 'client');
|
||||
|
||||
await cubit.load();
|
||||
|
||||
expect(cubit.state, isA<InsightsStateHidden>());
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('ошибка сервера → error', () async {
|
||||
repository.error = DioException(requestOptions: RequestOptions());
|
||||
final cubit = buildCubit();
|
||||
|
||||
await cubit.load();
|
||||
|
||||
expect(cubit.state, isA<InsightsStateError>());
|
||||
await cubit.close();
|
||||
});
|
||||
});
|
||||
|
||||
group('setDailyPeriod', () {
|
||||
test('перезапрашивает все данные, сохраняя период тела и день', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
await cubit.setBodyPeriod(BodyPeriod.year);
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||
repository.serverRequests.clear();
|
||||
|
||||
await cubit.setDailyPeriod(DailyPeriod.month);
|
||||
|
||||
expect(repository.serverRequests, [
|
||||
(
|
||||
slug: null,
|
||||
bodyPeriod: BodyPeriod.year,
|
||||
dailyPeriod: DailyPeriod.month,
|
||||
intraDate: '2026-11-08',
|
||||
),
|
||||
]);
|
||||
expect(loaded(cubit).dailyPeriod, DailyPeriod.month);
|
||||
expect(loaded(cubit).isRefreshing, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('тот же период не перезапрашивается', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
repository.serverRequests.clear();
|
||||
|
||||
await cubit.setDailyPeriod(DailyPeriod.twoWeeks);
|
||||
|
||||
expect(repository.serverRequests, isEmpty);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('ошибка возвращает прежний период и оставляет данные', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
final before = loaded(cubit).payload;
|
||||
repository.error = DioException(requestOptions: RequestOptions());
|
||||
|
||||
await cubit.setDailyPeriod(DailyPeriod.week);
|
||||
|
||||
expect(loaded(cubit).dailyPeriod, DailyPeriod.twoWeeks);
|
||||
expect(loaded(cubit).payload, before);
|
||||
expect(loaded(cubit).isRefreshing, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('смена периода тела передаёт текущий период КБЖУ', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
await cubit.setDailyPeriod(DailyPeriod.week);
|
||||
repository.serverRequests.clear();
|
||||
|
||||
await cubit.setBodyPeriod(BodyPeriod.month);
|
||||
|
||||
expect(repository.serverRequests.single.dailyPeriod, DailyPeriod.week);
|
||||
await cubit.close();
|
||||
});
|
||||
});
|
||||
|
||||
group('«В течение дня»', () {
|
||||
test(
|
||||
'setIntraDate запрашивает только intra и подменяет её в payload',
|
||||
() async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
repository.serverRequests.clear();
|
||||
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||
|
||||
expect(repository.serverRequests, isEmpty);
|
||||
expect(repository.intraRequests, [(slug: null, date: '2026-11-08')]);
|
||||
final state = loaded(cubit);
|
||||
expect(state.payload.intra, yesterday);
|
||||
expect(state.payload.weight, repository.payload.weight);
|
||||
expect(state.intraDate, '2026-11-08');
|
||||
expect(state.isRefreshing, isFalse);
|
||||
await cubit.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('slug передаётся в запрос чужого дня', () async {
|
||||
final cubit = buildCubit(slug: 'client');
|
||||
await cubit.load();
|
||||
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||
|
||||
expect(repository.intraRequests.single.slug, 'client');
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('showPreviousDay и showNextDay листают по дню', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
|
||||
await cubit.showPreviousDay();
|
||||
expect(repository.intraRequests.last.date, '2026-11-08');
|
||||
expect(loaded(cubit).payload.intra!.isToday, isFalse);
|
||||
|
||||
await cubit.showNextDay();
|
||||
expect(repository.intraRequests.last.date, '2026-11-09');
|
||||
expect(loaded(cubit).payload.intra!.isToday, isTrue);
|
||||
expect(loaded(cubit).intraDate, isNull);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('showPreviousDay переходит через границу месяца', () async {
|
||||
repository.payload = repository.payload.copyWith(
|
||||
intra: today.copyWith(date: '2026-11-01', isToday: false),
|
||||
);
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
|
||||
await cubit.showPreviousDay();
|
||||
|
||||
expect(repository.intraRequests.single.date, '2026-10-31');
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('showNextDay на сегодняшнем дне ничего не запрашивает', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
|
||||
await cubit.showNextDay();
|
||||
|
||||
expect(repository.intraRequests, isEmpty);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('сервер вернул другую дату (clamp) — берём её', () async {
|
||||
repository.intraByDate['2026-11-20'] = today;
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 20));
|
||||
|
||||
expect(loaded(cubit).intraDate, isNull);
|
||||
expect(loaded(cubit).payload.intra!.date, '2026-11-09');
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('пустой день: intra с пустыми сериями остаётся объектом', () async {
|
||||
final empty = today.copyWith(
|
||||
date: '2026-11-07',
|
||||
dateLabel: '7 ноября',
|
||||
isToday: false,
|
||||
bars: const [],
|
||||
satiety: const [],
|
||||
mood: const [],
|
||||
wake: null,
|
||||
sleep: null,
|
||||
wakeText: null,
|
||||
sleepText: null,
|
||||
);
|
||||
repository.intraByDate['2026-11-07'] = empty;
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 7));
|
||||
|
||||
expect(loaded(cubit).payload.intra, empty);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('ошибка запроса дня не стирает показанные данные', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
repository.intraError = DioException(requestOptions: RequestOptions());
|
||||
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||
|
||||
final state = loaded(cubit);
|
||||
expect(state.payload.intra, today);
|
||||
expect(state.intraDate, isNull);
|
||||
expect(state.isRefreshing, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('403 при листании скрывает блок', () async {
|
||||
final cubit = buildCubit(slug: 'client');
|
||||
await cubit.load();
|
||||
repository.intraError = const InsightsForbidden();
|
||||
|
||||
await cubit.showPreviousDay();
|
||||
|
||||
expect(cubit.state, isA<InsightsStateHidden>());
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('refresh сохраняет выбранный день, вкладку и серии', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
cubit.toggleExpanded();
|
||||
cubit.setPanel(InsightsPanel.intra);
|
||||
cubit.toggleSleep();
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||
repository.serverRequests.clear();
|
||||
|
||||
await cubit.refresh();
|
||||
|
||||
expect(repository.serverRequests.single.intraDate, '2026-11-08');
|
||||
final state = loaded(cubit);
|
||||
expect(state.panel, InsightsPanel.intra);
|
||||
expect(state.isExpanded, isTrue);
|
||||
expect(state.showSleep, isFalse);
|
||||
expect(state.intraDate, '2026-11-08');
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('refresh во время первой загрузки не дублирует запрос', () async {
|
||||
final cubit = buildCubit();
|
||||
final loading = cubit.load();
|
||||
|
||||
await cubit.refresh();
|
||||
await loading;
|
||||
|
||||
expect(repository.serverRequests, hasLength(1));
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('без флага навигация по дням игнорируется', () async {
|
||||
final cubit = InsightsCubit(repository: repository, kbjuEnabled: false);
|
||||
await cubit.load();
|
||||
|
||||
await cubit.showPreviousDay();
|
||||
await cubit.setDailyPeriod(DailyPeriod.week);
|
||||
|
||||
expect(repository.intraRequests, isEmpty);
|
||||
expect(repository.serverRequests, isEmpty);
|
||||
await cubit.close();
|
||||
});
|
||||
});
|
||||
|
||||
test('после добавления веса данные обновляются с сервера', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
repository.serverRequests.clear();
|
||||
|
||||
final isSaved = await cubit.addWeight(weight: 72.5);
|
||||
|
||||
expect(isSaved, isTrue);
|
||||
expect(repository.addedWeights, [72.5]);
|
||||
expect(repository.serverRequests, hasLength(1));
|
||||
await cubit.close();
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,7 @@ void main() {
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(BodyPeriod.month);
|
||||
registerFallbackValue(DailyPeriod.week);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
@@ -40,6 +41,14 @@ void main() {
|
||||
heightCm: any(named: 'heightCm'),
|
||||
),
|
||||
).thenAnswer((_) async => _payload);
|
||||
when(
|
||||
() => repository.fetchInsights(
|
||||
slug: any(named: 'slug'),
|
||||
bodyPeriod: any(named: 'bodyPeriod'),
|
||||
dailyPeriod: any(named: 'dailyPeriod'),
|
||||
intraDate: any(named: 'intraDate'),
|
||||
),
|
||||
).thenAnswer((_) async => _payload);
|
||||
});
|
||||
|
||||
InsightsCubit buildCubit({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
@@ -60,7 +61,7 @@ void main() {
|
||||
expect(find.byType(DailyKbjuChart), findsOneWidget);
|
||||
expect(find.text('1930'), findsOneWidget);
|
||||
expect(find.text('ккал в среднем'), findsOneWidget);
|
||||
expect(find.text('98% от нормы'), findsOneWidget);
|
||||
expect(find.text('Калораж вырос на 5% за период'), findsOneWidget);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
expect(find.byKey(Key('insights_series_$i')), findsOneWidget);
|
||||
}
|
||||
@@ -85,6 +86,20 @@ void main() {
|
||||
expect(chart().series, hasLength(3));
|
||||
});
|
||||
|
||||
testWidgets('пилюли периода активны и перезапрашивают данные', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeInsightsRepository();
|
||||
await pumpKbjuCard(tester, repository, 'insights_tab_daily');
|
||||
repository.serverRequests.clear();
|
||||
|
||||
await tester.tap(find.byKey(const Key('insights_period_30')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.serverRequests.single.dailyPeriod, DailyPeriod.month);
|
||||
expect(repository.serverRequests.single.bodyPeriod, BodyPeriod.threeMonths);
|
||||
});
|
||||
|
||||
testWidgets('без daily показывает заглушку', (tester) async {
|
||||
final payload = loadInsightsPayloadFixture().copyWith(daily: null);
|
||||
await pumpKbjuCard(
|
||||
@@ -135,26 +150,66 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'кнопка «следующий день» отключена при isToday, «предыдущий» без колбэка тоже',
|
||||
(tester) async {
|
||||
await pumpKbjuCard(
|
||||
tester,
|
||||
FakeInsightsRepository(),
|
||||
'insights_tab_intra',
|
||||
);
|
||||
testWidgets('на сегодняшнем дне «следующий день» отключён, «предыдущий» нет', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpKbjuCard(
|
||||
tester,
|
||||
FakeInsightsRepository(),
|
||||
'insights_tab_intra',
|
||||
);
|
||||
|
||||
IconButton buttonIn(String key) => tester.widget<IconButton>(
|
||||
find.descendant(
|
||||
of: find.byKey(Key(key)),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
IconButton buttonIn(String key) => tester.widget<IconButton>(
|
||||
find.descendant(
|
||||
of: find.byKey(Key(key)),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
|
||||
expect(buttonIn('insights_intra_next').onPressed, isNull);
|
||||
expect(buttonIn('insights_intra_prev').onPressed, isNull);
|
||||
},
|
||||
);
|
||||
expect(buttonIn('insights_intra_next').onPressed, isNull);
|
||||
expect(buttonIn('insights_intra_prev').onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('«предыдущий день» запрашивает только intra и меняет дату', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeInsightsRepository();
|
||||
repository.intraByDate['2026-11-08'] = loadInsightsPayloadFixture()
|
||||
.intra!
|
||||
.copyWith(date: '2026-11-08', dateLabel: '8 ноября', isToday: false);
|
||||
await pumpKbjuCard(tester, repository, 'insights_tab_intra');
|
||||
repository.serverRequests.clear();
|
||||
|
||||
await tester.tap(find.byKey(const Key('insights_intra_prev')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.intraRequests, [(slug: null, date: '2026-11-08')]);
|
||||
expect(repository.serverRequests, isEmpty);
|
||||
expect(find.text('8 ноября'), findsOneWidget);
|
||||
expect(find.text('Сегодня'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('с прошедшего дня можно вернуться «следующим днём»', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeInsightsRepository();
|
||||
final today = loadInsightsPayloadFixture().intra!;
|
||||
repository.intraByDate['2026-11-08'] = today.copyWith(
|
||||
date: '2026-11-08',
|
||||
dateLabel: '8 ноября',
|
||||
isToday: false,
|
||||
);
|
||||
repository.intraByDate['2026-11-09'] = today;
|
||||
await pumpKbjuCard(tester, repository, 'insights_tab_intra');
|
||||
|
||||
await tester.tap(find.byKey(const Key('insights_intra_prev')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('insights_intra_next')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.intraRequests.last.date, '2026-11-09');
|
||||
expect(find.text('Сегодня'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('чип «Сон и подъём» убирает затемнение и легенду не трогает', (
|
||||
tester,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_response_model.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/features/insights/services/insights_service.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
@@ -11,8 +13,12 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../fixtures/doubles/insights_doubles.dart';
|
||||
|
||||
class _MockProfileRepository extends Mock implements ProfileRepository {}
|
||||
|
||||
class _MockInsightsService extends Mock implements InsightsService {}
|
||||
|
||||
ProfileDetailsModel _details({int height = 176}) => ProfileDetailsModel(
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
@@ -51,6 +57,7 @@ DioException _dio(int status) => DioException(
|
||||
|
||||
void main() {
|
||||
late _MockProfileRepository profileRepository;
|
||||
late _MockInsightsService insightsService;
|
||||
late InsightsRepository repository;
|
||||
|
||||
final date1 = DateTime(2026, 11, 1, 12);
|
||||
@@ -89,8 +96,10 @@ void main() {
|
||||
setUp(() async {
|
||||
final s = await S.load(const Locale('ru'));
|
||||
profileRepository = _MockProfileRepository();
|
||||
insightsService = _MockInsightsService();
|
||||
repository = InsightsRepository(
|
||||
profileRepository: profileRepository,
|
||||
insightsService: insightsService,
|
||||
textBuilderFactory: () => InsightsTextBuilder(s),
|
||||
);
|
||||
});
|
||||
@@ -211,4 +220,122 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('fetchInsights (сервер)', () {
|
||||
final response = InsightsResponseModel(data: loadInsightsPayloadFixture());
|
||||
|
||||
test('свой профиль: /insights с периодами и датой', () async {
|
||||
when(
|
||||
() => insightsService.getInsights(
|
||||
bodyPeriod: '6',
|
||||
dailyPeriod: '30',
|
||||
intraDate: '2026-11-08',
|
||||
),
|
||||
).thenAnswer((_) async => response);
|
||||
|
||||
final payload = await repository.fetchInsights(
|
||||
bodyPeriod: BodyPeriod.sixMonths,
|
||||
dailyPeriod: DailyPeriod.month,
|
||||
intraDate: '2026-11-08',
|
||||
);
|
||||
|
||||
expect(payload, response.data);
|
||||
verifyNever(
|
||||
() => insightsService.getInsightsBySlug(
|
||||
slug: any(named: 'slug'),
|
||||
bodyPeriod: any(named: 'bodyPeriod'),
|
||||
dailyPeriod: any(named: 'dailyPeriod'),
|
||||
intraDate: any(named: 'intraDate'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('чужой профиль: /insights/{slug}', () async {
|
||||
when(
|
||||
() => insightsService.getInsightsBySlug(
|
||||
slug: 'client',
|
||||
bodyPeriod: 'all',
|
||||
dailyPeriod: 'all',
|
||||
intraDate: null,
|
||||
),
|
||||
).thenAnswer((_) async => response);
|
||||
|
||||
final payload = await repository.fetchInsights(
|
||||
slug: 'client',
|
||||
bodyPeriod: BodyPeriod.all,
|
||||
dailyPeriod: DailyPeriod.all,
|
||||
);
|
||||
|
||||
expect(payload, response.data);
|
||||
});
|
||||
|
||||
test('403 → InsightsForbidden', () {
|
||||
when(
|
||||
() => insightsService.getInsightsBySlug(
|
||||
slug: 'client',
|
||||
bodyPeriod: '3',
|
||||
dailyPeriod: '14',
|
||||
intraDate: null,
|
||||
),
|
||||
).thenThrow(_dio(403));
|
||||
|
||||
expect(
|
||||
repository.fetchInsights(
|
||||
slug: 'client',
|
||||
bodyPeriod: BodyPeriod.threeMonths,
|
||||
dailyPeriod: DailyPeriod.twoWeeks,
|
||||
),
|
||||
throwsA(isA<InsightsForbidden>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('422 пробрасывается как DioException', () {
|
||||
when(
|
||||
() => insightsService.getInsights(
|
||||
bodyPeriod: '3',
|
||||
dailyPeriod: '14',
|
||||
intraDate: null,
|
||||
),
|
||||
).thenThrow(_dio(422));
|
||||
|
||||
expect(
|
||||
repository.fetchInsights(
|
||||
bodyPeriod: BodyPeriod.threeMonths,
|
||||
dailyPeriod: DailyPeriod.twoWeeks,
|
||||
),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('fetchIntra (сервер)', () {
|
||||
final intra = loadInsightsPayloadFixture().intra!;
|
||||
final response = InsightsIntraResponseModel(
|
||||
data: InsightsIntraDataModel(intra: intra),
|
||||
);
|
||||
|
||||
test('свой профиль: /insights/intra', () async {
|
||||
when(
|
||||
() => insightsService.getIntra(intraDate: '2026-11-09'),
|
||||
).thenAnswer((_) async => response);
|
||||
|
||||
expect(await repository.fetchIntra(intraDate: '2026-11-09'), intra);
|
||||
});
|
||||
|
||||
test('чужой профиль: /insights/{slug}/intra', () async {
|
||||
when(
|
||||
() => insightsService.getIntraBySlug(slug: 'client', intraDate: null),
|
||||
).thenAnswer((_) async => response);
|
||||
|
||||
expect(await repository.fetchIntra(slug: 'client'), intra);
|
||||
});
|
||||
|
||||
test('403 → InsightsForbidden', () {
|
||||
when(
|
||||
() => insightsService.getIntra(intraDate: null),
|
||||
).thenThrow(_dio(403));
|
||||
|
||||
expect(repository.fetchIntra(), throwsA(isA<InsightsForbidden>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
|
||||
as profile_plans;
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/profile_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
@@ -20,9 +22,15 @@ import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:network_image_mock/network_image_mock.dart';
|
||||
|
||||
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||
import 'profile_screen_smoke_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<ProfileRepository>(), MockSpec<ChatRepository>(), MockSpec<ConsultantRepository>(), MockSpec<PlansRepository>()])
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<ProfileRepository>(),
|
||||
MockSpec<ChatRepository>(),
|
||||
MockSpec<ConsultantRepository>(),
|
||||
MockSpec<PlansRepository>(),
|
||||
])
|
||||
void main() {
|
||||
group('ProfileScreen smoke', () {
|
||||
late MockProfileRepository profileRepository;
|
||||
@@ -31,12 +39,18 @@ void main() {
|
||||
late MockPlansRepository plansRepository;
|
||||
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
|
||||
profileRepository = MockProfileRepository();
|
||||
chatRepository = MockChatRepository();
|
||||
@@ -44,13 +58,25 @@ void main() {
|
||||
plansRepository = MockPlansRepository();
|
||||
|
||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
|
||||
sl.registerLazySingleton<InsightsRepository>(
|
||||
() => FakeInsightsRepository(),
|
||||
);
|
||||
sl.registerLazySingleton<ChatRepository>(() => chatRepository);
|
||||
sl.registerLazySingleton<ConsultantRepository>(() => consultantRepository);
|
||||
sl.registerLazySingleton<ConsultantRepository>(
|
||||
() => consultantRepository,
|
||||
);
|
||||
sl.registerLazySingleton<PlansRepository>(() => plansRepository);
|
||||
|
||||
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
|
||||
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
|
||||
sl.registerFactory<ProfileCubit>(
|
||||
() => ProfileCubit(
|
||||
profileRepository: sl(),
|
||||
chatRepository: sl(),
|
||||
consultantRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<profile_plans.PlansCubit>(
|
||||
() => profile_plans.PlansCubit(plansRepository: sl()),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -115,8 +141,12 @@ void main() {
|
||||
);
|
||||
|
||||
when(profileRepository.getMyProfile()).thenAnswer((_) async => profile);
|
||||
when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails);
|
||||
when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []);
|
||||
when(
|
||||
profileRepository.getMyProfileDetails(),
|
||||
).thenAnswer((_) async => profileDetails);
|
||||
when(
|
||||
profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')),
|
||||
).thenAnswer((_) async => []);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(const ProfileScreen()));
|
||||
@@ -127,8 +157,12 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('renders error state', (tester) async {
|
||||
when(profileRepository.getMyProfile()).thenThrow(Exception('Error loading profile'));
|
||||
when(profileRepository.getMyProfileDetails()).thenThrow(Exception('Error'));
|
||||
when(
|
||||
profileRepository.getMyProfile(),
|
||||
).thenThrow(Exception('Error loading profile'));
|
||||
when(
|
||||
profileRepository.getMyProfileDetails(),
|
||||
).thenThrow(Exception('Error'));
|
||||
|
||||
await tester.pumpWidget(wrap(const ProfileScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -183,11 +217,19 @@ void main() {
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
when(profileRepository.getProfileBySlug(any)).thenAnswer((_) async => profile);
|
||||
when(
|
||||
profileRepository.getProfileBySlug(any),
|
||||
).thenAnswer((_) async => profile);
|
||||
when(profileRepository.getMyProfile()).thenAnswer((_) async => profile);
|
||||
when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails);
|
||||
when(profileRepository.getProfileDetailsBySlug(any)).thenAnswer((_) async => profileDetails);
|
||||
when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []);
|
||||
when(
|
||||
profileRepository.getMyProfileDetails(),
|
||||
).thenAnswer((_) async => profileDetails);
|
||||
when(
|
||||
profileRepository.getProfileDetailsBySlug(any),
|
||||
).thenAnswer((_) async => profileDetails);
|
||||
when(
|
||||
profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')),
|
||||
).thenAnswer((_) async => []);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(const ProfileScreen(slug: 'test-user')));
|
||||
@@ -195,7 +237,159 @@ void main() {
|
||||
});
|
||||
|
||||
expect(find.byType(ProfileScreen), findsOneWidget);
|
||||
expect(find.text('Пользователь ограничил доступ к своей странице'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Пользователь ограничил доступ к своей странице'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
group('аналитика (InsightsCard)', () {
|
||||
ProfileModel profileOf(
|
||||
String slug, {
|
||||
bool blockedByMe = false,
|
||||
bool blockedByAuthor = false,
|
||||
}) => ProfileModel(
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
slug: slug,
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: blockedByMe,
|
||||
isBlockedByAuthor: blockedByAuthor,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: '',
|
||||
roles: const [],
|
||||
badges: const [],
|
||||
subscriptions: const [],
|
||||
subscribers: const [],
|
||||
);
|
||||
|
||||
const details = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'male',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 70.0,
|
||||
height: 175,
|
||||
chestCircumference: 100,
|
||||
waistCircumference: 80,
|
||||
hipCircumference: 90,
|
||||
bloodPressureUp: 120,
|
||||
bloodPressureDown: 80,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: '',
|
||||
aboutTakingPils: '',
|
||||
aboutSportHobbies: '',
|
||||
favoriteFoods: '',
|
||||
unfavoriteFoods: '',
|
||||
additionalInfo: '',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
Future<FakeInsightsRepository> pumpProfile(
|
||||
WidgetTester tester, {
|
||||
required ProfileModel viewed,
|
||||
String? slug,
|
||||
Object? insightsError,
|
||||
}) async {
|
||||
final insights = FakeInsightsRepository(error: insightsError);
|
||||
sl.unregister<InsightsRepository>();
|
||||
sl.registerLazySingleton<InsightsRepository>(() => insights);
|
||||
when(
|
||||
profileRepository.getProfileBySlug(any),
|
||||
).thenAnswer((_) async => viewed);
|
||||
when(
|
||||
profileRepository.getMyProfile(),
|
||||
).thenAnswer((_) async => profileOf('me'));
|
||||
when(
|
||||
profileRepository.getMyProfileDetails(),
|
||||
).thenAnswer((_) async => details);
|
||||
when(
|
||||
profileRepository.getProfileDetailsBySlug(any),
|
||||
).thenAnswer((_) async => details);
|
||||
when(
|
||||
profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')),
|
||||
).thenAnswer((_) async => []);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(ProfileScreen(slug: slug)));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
return insights;
|
||||
}
|
||||
|
||||
testWidgets('собственный профиль: свои данные и кнопка добавления', (
|
||||
tester,
|
||||
) async {
|
||||
final insights = await pumpProfile(tester, viewed: profileOf('me'));
|
||||
|
||||
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||
expect(card.slug, isNull);
|
||||
expect(card.canAdd, isTrue);
|
||||
expect(insights.serverRequests.single.slug, isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'чужой профиль (консультант → клиент): данные по slug без кнопки добавления',
|
||||
(tester) async {
|
||||
final insights = await pumpProfile(
|
||||
tester,
|
||||
viewed: profileOf('client'),
|
||||
slug: 'client',
|
||||
);
|
||||
|
||||
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||
expect(card.slug, 'client');
|
||||
expect(card.canAdd, isFalse);
|
||||
expect(insights.serverRequests.single.slug, 'client');
|
||||
expect(find.byKey(const Key('insights_card')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('чужой профиль: 403 скрывает блок', (tester) async {
|
||||
await pumpProfile(
|
||||
tester,
|
||||
viewed: profileOf('client'),
|
||||
slug: 'client',
|
||||
insightsError: const InsightsForbidden(),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('insights_card')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'профиль, заблокированный мной, не показывает аналитику и не запрашивает её',
|
||||
(tester) async {
|
||||
final insights = await pumpProfile(
|
||||
tester,
|
||||
viewed: profileOf('client', blockedByMe: true),
|
||||
slug: 'client',
|
||||
);
|
||||
|
||||
expect(find.byType(InsightsCard), findsNothing);
|
||||
expect(insights.serverRequests, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('профиль, ограничивший доступ, не показывает аналитику', (
|
||||
tester,
|
||||
) async {
|
||||
final insights = await pumpProfile(
|
||||
tester,
|
||||
viewed: profileOf('client', blockedByAuthor: true),
|
||||
slug: 'client',
|
||||
);
|
||||
|
||||
expect(find.byType(InsightsCard), findsNothing);
|
||||
expect(insights.serverRequests, isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/doubles/insights_doubles.dart';
|
||||
import 'user_details_screen_smoke_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<ProfileRepository>()])
|
||||
@@ -102,7 +103,7 @@ void main() {
|
||||
when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
|
||||
|
||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
|
||||
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
||||
|
||||
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
||||
@@ -123,7 +124,7 @@ void main() {
|
||||
when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
|
||||
|
||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
|
||||
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
||||
|
||||
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
||||
@@ -139,7 +140,7 @@ void main() {
|
||||
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenThrow(Exception('Error loading profile'));
|
||||
|
||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
|
||||
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
||||
|
||||
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
||||
|
||||
Reference in New Issue
Block a user