feat(insights): added kbju fetching for analitics

This commit is contained in:
misha
2026-11-10 12:21:53 +03:00
parent 45d072cff4
commit 8c33f033ff
30 changed files with 1910 additions and 118 deletions

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);
});

View 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 ноября');
});
});
}

View File

@@ -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();
});
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -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>()));
});
});
}

View File

@@ -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);
});
});
});
}

View File

@@ -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')));