feat(insights): first isights impemetation

This commit is contained in:
misha
2026-11-09 15:35:10 +03:00
parent 67920b2016
commit 45d072cff4
63 changed files with 6374 additions and 1465 deletions

View File

@@ -0,0 +1,430 @@
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 'package:mocktail/mocktail.dart';
class _MockInsightsRepository extends Mock implements InsightsRepository {}
const _payload = InsightsPayloadModel(
weight: WeightChartModel(values: [70, 71], headline: '71.0'),
bmi: BmiChartModel(),
measure: MeasureChartModel(),
);
const _payload2 = InsightsPayloadModel(
weight: WeightChartModel(values: [72], headline: '72.0'),
bmi: BmiChartModel(),
measure: MeasureChartModel(),
);
void main() {
late _MockInsightsRepository repository;
setUpAll(() {
registerFallbackValue(BodyPeriod.month);
});
setUp(() {
repository = _MockInsightsRepository();
when(
() => repository.getHeightCm(slug: any(named: 'slug')),
).thenAnswer((_) async => 176);
when(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
).thenAnswer((_) async => _payload);
});
InsightsCubit buildCubit({
String? slug,
bool canAdd = false,
bool kbjuEnabled = false,
}) {
return InsightsCubit(
repository: repository,
slug: slug,
canAdd: canAdd,
kbjuEnabled: kbjuEnabled,
);
}
InsightsStateLoaded loaded(InsightsCubit cubit) =>
cubit.state as InsightsStateLoaded;
test('начальное состояние — initial', () {
expect(buildCubit().state, isA<InsightsStateInitial>());
});
group('load', () {
test('дефолты: период 3М, вкладка «Вес», блок свёрнут', () async {
final cubit = buildCubit();
final states = <InsightsState>[];
cubit.stream.listen(states.add);
await cubit.load();
await Future<void>.delayed(Duration.zero);
expect(states.first, isA<InsightsStateLoading>());
final state = loaded(cubit);
expect(state.bodyPeriod, BodyPeriod.threeMonths);
expect(state.panel, InsightsPanel.weight);
expect(state.isExpanded, isFalse);
expect(state.isRefreshing, isFalse);
expect(state.showSleep, isTrue);
expect(state.payload, _payload);
verify(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).called(1);
await cubit.close();
});
test('пробрасывает slug владельца', () async {
final cubit = buildCubit(slug: 'client');
await cubit.load();
verify(() => repository.getHeightCm(slug: 'client')).called(1);
verify(
() => repository.getInsights(
slug: 'client',
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).called(1);
await cubit.close();
});
test('403 → hidden', () async {
when(
() => repository.getHeightCm(slug: any(named: 'slug')),
).thenThrow(const InsightsForbidden());
final cubit = buildCubit(slug: 'client');
await cubit.load();
expect(cubit.state, isA<InsightsStateHidden>());
await cubit.close();
});
test('403 на запросе точек → hidden', () async {
when(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
).thenThrow(const InsightsForbidden());
final cubit = buildCubit(slug: 'client');
await cubit.load();
expect(cubit.state, isA<InsightsStateHidden>());
await cubit.close();
});
test('другая ошибка → error', () async {
when(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
).thenThrow(Exception('boom'));
final cubit = buildCubit();
await cubit.load();
expect(cubit.state, isA<InsightsStateError>());
await cubit.close();
});
});
group('setBodyPeriod', () {
test(
'смена периода → перезапрос; рост не запрашивается повторно',
() async {
final cubit = buildCubit();
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.year,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
await cubit.setBodyPeriod(BodyPeriod.year);
expect(loaded(cubit).bodyPeriod, BodyPeriod.year);
expect(loaded(cubit).payload, _payload2);
expect(loaded(cubit).isRefreshing, isFalse);
verify(() => repository.getHeightCm(slug: null)).called(1);
await cubit.close();
},
);
test('тот же период — без запроса', () async {
final cubit = buildCubit();
await cubit.load();
clearInteractions(repository);
await cubit.setBodyPeriod(BodyPeriod.threeMonths);
verifyNever(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
);
await cubit.close();
});
test('ошибка перезапроса возвращает прежний период и данные', () async {
final cubit = buildCubit();
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.all,
heightCm: 176,
),
).thenThrow(Exception('boom'));
await cubit.setBodyPeriod(BodyPeriod.all);
expect(loaded(cubit).bodyPeriod, BodyPeriod.threeMonths);
expect(loaded(cubit).payload, _payload);
expect(loaded(cubit).isRefreshing, isFalse);
await cubit.close();
});
test('403 при перезапросе → hidden', () async {
final cubit = buildCubit(slug: 'c');
await cubit.load();
when(
() => repository.getInsights(
slug: 'c',
bodyPeriod: BodyPeriod.month,
heightCm: 176,
),
).thenThrow(const InsightsForbidden());
await cubit.setBodyPeriod(BodyPeriod.month);
expect(cubit.state, isA<InsightsStateHidden>());
await cubit.close();
});
});
group('панели и серии', () {
test('toggleExpanded раскрывает и сворачивает', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleExpanded();
expect(loaded(cubit).isExpanded, isTrue);
cubit.toggleExpanded();
expect(loaded(cubit).isExpanded, isFalse);
await cubit.close();
});
test('setPanel переключает вкладку', () async {
final cubit = buildCubit();
await cubit.load();
cubit.setPanel(InsightsPanel.measures);
expect(loaded(cubit).panel, InsightsPanel.measures);
await cubit.close();
});
test('вкладки КБЖУ недоступны при выключенном флаге', () async {
final cubit = buildCubit();
await cubit.load();
cubit.setPanel(InsightsPanel.daily);
expect(loaded(cubit).panel, InsightsPanel.weight);
expect(cubit.panels, [
InsightsPanel.weight,
InsightsPanel.bmi,
InsightsPanel.measures,
]);
await cubit.close();
});
test('вкладки КБЖУ доступны при включённом флаге', () async {
final cubit = buildCubit(kbjuEnabled: true);
await cubit.load();
cubit.setPanel(InsightsPanel.intra);
expect(loaded(cubit).panel, InsightsPanel.intra);
expect(cubit.panels, InsightsPanel.values);
await cubit.close();
});
test('toggleSeries переключает только выбранную серию', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleSeries(InsightsPanel.measures, 1);
expect(loaded(cubit).seriesVisibility.measure, [true, false, true]);
cubit.toggleSeries(InsightsPanel.measures, 1);
expect(loaded(cubit).seriesVisibility.measure, [true, true, true]);
cubit.toggleSeries(InsightsPanel.daily, 0);
expect(loaded(cubit).seriesVisibility.daily, [false, true, true, true]);
expect(loaded(cubit).seriesVisibility.measure, [true, true, true]);
await cubit.close();
});
test(
'toggleSeries игнорирует неверный индекс и вкладку без серий',
() async {
final cubit = buildCubit();
await cubit.load();
final before = loaded(cubit).seriesVisibility;
cubit.toggleSeries(InsightsPanel.measures, 5);
cubit.toggleSeries(InsightsPanel.weight, 0);
expect(loaded(cubit).seriesVisibility, before);
await cubit.close();
},
);
test('toggleSleep', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleSleep();
expect(loaded(cubit).showSleep, isFalse);
await cubit.close();
});
});
group('refresh и добавление записей', () {
test('refresh сохраняет вкладку, период, серии и раскрытие', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleExpanded();
cubit.setPanel(InsightsPanel.measures);
cubit.toggleSeries(InsightsPanel.measures, 0);
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
await cubit.refresh();
final state = loaded(cubit);
expect(state.payload, _payload2);
expect(state.panel, InsightsPanel.measures);
expect(state.isExpanded, isTrue);
expect(state.seriesVisibility.measure, [false, true, true]);
expect(state.isRefreshing, isFalse);
await cubit.close();
});
test('addWeight сохраняет и обновляет данные', () async {
when(
() => repository.addWeight(weight: any(named: 'weight')),
).thenAnswer((_) async {});
final cubit = buildCubit(canAdd: true);
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
final isSaved = await cubit.addWeight(weight: 72);
expect(isSaved, isTrue);
verify(() => repository.addWeight(weight: 72)).called(1);
expect(loaded(cubit).payload, _payload2);
await cubit.close();
});
test('addCircumference сохраняет и обновляет данные', () async {
when(
() => repository.addCircumference(
chestCircumference: any(named: 'chestCircumference'),
waistCircumference: any(named: 'waistCircumference'),
hipCircumference: any(named: 'hipCircumference'),
),
).thenAnswer((_) async {});
final cubit = buildCubit(canAdd: true);
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
final isSaved = await cubit.addCircumference(
chestCircumference: 95,
waistCircumference: 80,
hipCircumference: 100,
);
expect(isSaved, isTrue);
verify(
() => repository.addCircumference(
chestCircumference: 95,
waistCircumference: 80,
hipCircumference: 100,
),
).called(1);
expect(loaded(cubit).payload, _payload2);
await cubit.close();
});
test(
'ошибка сохранения не перезагружает данные и возвращает false',
() async {
when(
() => repository.addWeight(weight: any(named: 'weight')),
).thenThrow(Exception('boom'));
final cubit = buildCubit(canAdd: true);
await cubit.load();
clearInteractions(repository);
expect(await cubit.addWeight(weight: 72), isFalse);
verifyNever(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
);
expect(loaded(cubit).payload, _payload);
await cubit.close();
},
);
});
}

View File

@@ -0,0 +1,382 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/charts/bmi_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.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/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../fixtures/doubles/insights_doubles.dart';
void main() {
Widget wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: SingleChildScrollView(child: child)),
);
}
Future<void> pumpCard(
WidgetTester tester,
FakeInsightsRepository repository, {
String? slug,
bool canAdd = false,
bool kbjuEnabled = false,
bool expand = true,
}) async {
tester.view.physicalSize = const Size(900, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrap(
InsightsCard(
slug: slug,
canAdd: canAdd,
repository: repository,
kbjuEnabled: kbjuEnabled,
),
),
);
await tester.pumpAndSettle();
if (expand &&
find.byKey(const Key('insights_toggle')).evaluate().isNotEmpty) {
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
}
}
group('свёрнутое состояние', () {
testWidgets('по умолчанию свёрнут: виден заголовок, графиков нет', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository(), expand: false);
expect(find.text('Аналитика'), findsOneWidget);
expect(find.text('нажмите, чтобы посмотреть прогресс'), findsOneWidget);
expect(find.byType(WeightChart), findsNothing);
expect(find.byKey(const Key('insights_tab_weight')), findsNothing);
});
testWidgets('тап по заголовку раскрывает и сворачивает блок', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository(), expand: false);
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
expect(find.byType(WeightChart), findsOneWidget);
expect(find.text('вес, замеры и питание'), findsOneWidget);
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
expect(find.byType(WeightChart), findsNothing);
});
});
group('вкладки', () {
testWidgets(
'показаны Вес, ИМТ, Замеры; вкладки КБЖУ скрыты при выключенном флаге',
(tester) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.byKey(const Key('insights_tab_weight')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_bmi')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_measures')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_daily')), findsNothing);
expect(find.byKey(const Key('insights_tab_intra')), findsNothing);
},
);
testWidgets('вкладки переключают панели', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.byType(WeightChart), findsOneWidget);
expect(find.byKey(const Key('insights_headline')), findsOneWidget);
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
expect(find.byType(MeasureChart), findsOneWidget);
expect(find.byType(WeightChart), findsNothing);
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.byType(BmiChart), findsOneWidget);
expect(find.byType(MeasureChart), findsNothing);
await tester.tap(find.byKey(const Key('insights_tab_weight')));
await tester.pumpAndSettle();
expect(find.byType(WeightChart), findsOneWidget);
});
testWidgets('вкладки КБЖУ видны при включённом флаге', (tester) async {
await pumpCard(tester, FakeInsightsRepository(), kbjuEnabled: true);
expect(find.byKey(const Key('insights_tab_daily')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_intra')), findsOneWidget);
});
});
group('вес', () {
testWidgets('показывает крупное число и текст дельты из payload', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.text('73.2'), findsOneWidget);
expect(find.text('кг'), findsWidgets);
expect(find.text('Снизили на 1.3 кг за период'), findsOneWidget);
});
});
group('пилюли периодов', () {
testWidgets(
'по умолчанию запрошены 3 месяца; смена периода перезапрашивает данные',
(tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository);
expect(
repository.insightsRequests.single.period,
BodyPeriod.threeMonths,
);
await tester.tap(find.byKey(const Key('insights_period_12')));
await tester.pumpAndSettle();
expect(repository.insightsRequests.map((e) => e.period), [
BodyPeriod.threeMonths,
BodyPeriod.year,
]);
await tester.tap(find.byKey(const Key('insights_period_all')));
await tester.pumpAndSettle();
expect(repository.insightsRequests.last.period, BodyPeriod.all);
},
);
testWidgets('повторный тап по активной пилюле не делает запрос', (
tester,
) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository);
await tester.tap(find.byKey(const Key('insights_period_3')));
await tester.pumpAndSettle();
expect(repository.insightsRequests, hasLength(1));
});
testWidgets('slug пробрасывается в запросы', (tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository, slug: 'client');
await tester.tap(find.byKey(const Key('insights_period_1')));
await tester.pumpAndSettle();
expect(repository.insightsRequests.map((e) => e.slug), [
'client',
'client',
]);
});
});
group('кнопка «+»', () {
testWidgets('нет при canAdd = false', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.byKey(const Key('insights_fab')), findsNothing);
});
testWidgets('есть на вкладках «Вес» и «Замеры», нет на «ИМТ»', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository(), canAdd: true);
expect(find.byKey(const Key('insights_fab')), findsOneWidget);
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_fab')), findsNothing);
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_fab')), findsOneWidget);
});
testWidgets('нет в свёрнутом состоянии', (tester) async {
await pumpCard(
tester,
FakeInsightsRepository(),
canAdd: true,
expand: false,
);
expect(find.byKey(const Key('insights_fab')), findsNothing);
});
testWidgets(
'сохранение веса отправляет значение и перезапрашивает данные',
(tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository, canAdd: true);
await tester.tap(find.byKey(const Key('insights_fab')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('add_weight_kilo_picker')), findsOneWidget);
await tester.tap(find.byKey(const Key('add_sheet_save')));
await tester.pumpAndSettle();
// Начальное значение шторки — последний вес из payload (73.2).
expect(repository.addedWeights, [73.2]);
expect(repository.insightsRequests, hasLength(2));
},
);
testWidgets(
'сохранение замеров отправляет значения и перезапрашивает данные',
(tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository, canAdd: true);
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_fab')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('add_circumference_waist_picker')),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('add_sheet_save')));
await tester.pumpAndSettle();
// Последние значения из payload: грудь 96, талия 80.5 → 81 (округление), бёдра 100.
expect(repository.addedCircumferences, [
(chest: 96, waist: 81, hip: 100),
]);
expect(repository.insightsRequests, hasLength(2));
},
);
});
group('серии замеров', () {
testWidgets('чипы переключают видимость серии', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_series_0')), findsOneWidget);
expect(find.byKey(const Key('insights_series_1')), findsOneWidget);
expect(find.byKey(const Key('insights_series_2')), findsOneWidget);
expect(
tester
.widget<Opacity>(
find
.ancestor(
of: find.byKey(const Key('insights_series_1')),
matching: find.byType(Opacity),
)
.first,
)
.opacity,
1,
);
await tester.tap(find.byKey(const Key('insights_series_1')));
await tester.pumpAndSettle();
expect(
tester
.widget<Opacity>(
find
.ancestor(
of: find.byKey(const Key('insights_series_1')),
matching: find.byType(Opacity),
)
.first,
)
.opacity,
0.45,
);
});
});
group('ИМТ', () {
testWidgets('без значений показывает только deltaText', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.text('Укажите рост, чтобы посчитать ИМТ.'), findsOneWidget);
expect(find.byKey(const Key('insights_bmi_chart')), findsNothing);
expect(find.byKey(const Key('insights_headline')), findsNothing);
});
testWidgets('со значениями рисует график и подсказку', (tester) async {
final payload = loadInsightsPayloadFixture().copyWith.bmi(
labels: ['01.11', '09.11'],
values: [26.1, 24.2],
last: 24.2,
delta: -1.9,
headline: '24.2',
category: 'норма',
deltaText: 'ИМТ ниже на 1.9 за 3 месяца · норма',
hint: 'Считаем при росте 176 см.',
);
await pumpCard(tester, FakeInsightsRepository(payload: payload));
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_bmi_chart')), findsOneWidget);
expect(find.byKey(const Key('insights_bmi_hint')), findsOneWidget);
expect(find.text('ИМТ ниже на 1.9 за 3 месяца · норма'), findsOneWidget);
});
});
group('скрытие и ошибки', () {
testWidgets('403 → блок скрыт целиком', (tester) async {
await pumpCard(
tester,
FakeInsightsRepository(error: const InsightsForbidden()),
slug: 'client',
expand: false,
);
expect(find.byKey(const Key('insights_card')), findsNothing);
expect(find.text('Аналитика'), findsNothing);
});
testWidgets(
'ошибка загрузки показывает кнопку «Повторить», повтор грузит данные',
(tester) async {
final repository = FakeInsightsRepository(error: Exception('boom'));
await pumpCard(tester, repository, expand: false);
expect(find.text('Не удалось загрузить аналитику'), findsOneWidget);
repository.error = null;
await tester.tap(find.byKey(const Key('insights_retry')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_toggle')), findsOneWidget);
expect(find.text('Не удалось загрузить аналитику'), findsNothing);
},
);
});
}

View File

@@ -0,0 +1,367 @@
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/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';
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import '../../../../fixtures/doubles/insights_doubles.dart';
void main() {
Widget wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: SingleChildScrollView(child: child)),
);
}
Future<void> pumpKbjuCard(
WidgetTester tester,
FakeInsightsRepository repository,
String tabKey,
) async {
tester.view.physicalSize = const Size(900, 2600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrap(InsightsCard(repository: repository, kbjuEnabled: true)),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key(tabKey)));
await tester.pumpAndSettle();
}
group('КБЖУ по дням', () {
testWidgets('рисует график, шапку со средним и 4 чипа серий', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_daily',
);
expect(find.byType(DailyKbjuChart), findsOneWidget);
expect(find.text('1930'), findsOneWidget);
expect(find.text('ккал в среднем'), findsOneWidget);
expect(find.text('98% от нормы'), findsOneWidget);
for (var i = 0; i < 4; i++) {
expect(find.byKey(Key('insights_series_$i')), findsOneWidget);
}
expect(find.byKey(const Key('insights_series_4')), findsNothing);
});
testWidgets('чипы скрывают серии', (tester) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_daily',
);
SfCartesianChart chart() => tester.widget<SfCartesianChart>(
find.byKey(const Key('insights_daily_chart')),
);
expect(chart().series, hasLength(4));
await tester.tap(find.byKey(const Key('insights_series_1')));
await tester.pumpAndSettle();
expect(chart().series, hasLength(3));
});
testWidgets('без daily показывает заглушку', (tester) async {
final payload = loadInsightsPayloadFixture().copyWith(daily: null);
await pumpKbjuCard(
tester,
FakeInsightsRepository(payload: payload),
'insights_tab_daily',
);
expect(find.byType(DailyKbjuChart), findsNothing);
expect(find.text('Недостаточно данных'), findsOneWidget);
});
testWidgets('пустой daily показывает заглушку вместо графика', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(
payload: loadInsightsPayloadFixture('insights_payload_minimal.json'),
),
'insights_tab_daily',
);
expect(find.byKey(const Key('insights_daily_chart')), findsNothing);
expect(find.text('Недостаточно данных'), findsWidgets);
});
});
group('В течение дня', () {
testWidgets('рисует график, навигацию по дням, легенду и подсказку', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_intra',
);
expect(find.byType(IntraDayChart), findsOneWidget);
expect(find.byType(IntraDateNav), findsOneWidget);
expect(find.text('Сегодня'), findsOneWidget);
expect(find.text('Подъём в 07:30'), findsOneWidget);
expect(find.text('Отход ко сну в 23:00'), findsOneWidget);
expect(find.text('Затемнение — время сна'), findsOneWidget);
expect(find.byKey(const Key('insights_intra_hint')), findsOneWidget);
for (var i = 0; i < 6; i++) {
expect(find.byKey(Key('insights_series_$i')), findsOneWidget);
}
});
testWidgets(
'кнопка «следующий день» отключена при isToday, «предыдущий» без колбэка тоже',
(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),
),
);
expect(buttonIn('insights_intra_next').onPressed, isNull);
expect(buttonIn('insights_intra_prev').onPressed, isNull);
},
);
testWidgets('чип «Сон и подъём» убирает затемнение и легенду не трогает', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_intra',
);
NumericAxis xAxis() =>
tester
.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
)
.primaryXAxis
as NumericAxis;
// wake + sleep: две полосы затемнения и две линии.
expect(xAxis().plotBands, hasLength(4));
await tester.tap(find.byKey(const Key('insights_series_5')));
await tester.pumpAndSettle();
expect(xAxis().plotBands, isEmpty);
expect(find.text('Затемнение — время сна'), findsOneWidget);
});
testWidgets('чипы сытости и настроения скрывают линии', (tester) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_intra',
);
int seriesCount() => tester
.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
)
.series
.length;
// 3 столбика + сытость + настроение.
expect(seriesCount(), 5);
await tester.tap(find.byKey(const Key('insights_series_3')));
await tester.pumpAndSettle();
expect(seriesCount(), 4);
await tester.tap(find.byKey(const Key('insights_series_4')));
await tester.pumpAndSettle();
expect(seriesCount(), 3);
});
testWidgets('пустые линии не создают серий (без экстраполяции)', (
tester,
) async {
final intra = loadInsightsPayloadFixture().intra!.copyWith(
satiety: const [],
mood: const [],
);
final payload = loadInsightsPayloadFixture().copyWith(intra: intra);
await pumpKbjuCard(
tester,
FakeInsightsRepository(payload: payload),
'insights_tab_intra',
);
final chart = tester.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
);
expect(chart.series, hasLength(3));
});
testWidgets('без wake/sleep затемнения нет', (tester) async {
const intra = IntraChartModel(
bars: [IntraBarModel(x: 12, p: 100, f: 50, c: 200)],
date: '2026-11-09',
maxDate: '2026-11-09',
dateLabel: 'Сегодня',
isToday: true,
);
final payload = loadInsightsPayloadFixture().copyWith(intra: intra);
await pumpKbjuCard(
tester,
FakeInsightsRepository(payload: payload),
'insights_tab_intra',
);
final xAxis =
tester
.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
)
.primaryXAxis
as NumericAxis;
expect(xAxis.plotBands, isEmpty);
expect(find.byKey(const Key('insights_legend_wake')), findsNothing);
expect(find.byKey(const Key('insights_legend_sleep')), findsNothing);
expect(find.byType(IntraLegend), findsOneWidget);
});
testWidgets('пустой день показывает заглушку', (tester) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(
payload: loadInsightsPayloadFixture('insights_payload_minimal.json'),
),
'insights_tab_intra',
);
expect(find.byKey(const Key('insights_intra_chart')), findsNothing);
expect(find.text('Недостаточно данных'), findsOneWidget);
});
});
group('IntraDateNav', () {
const model = IntraChartModel(
date: '2026-11-08',
maxDate: '2026-11-09',
dateLabel: '8 ноября',
isToday: false,
);
testWidgets('вызывает колбэки предыдущего и следующего дня', (
tester,
) async {
var prev = 0;
var next = 0;
await tester.pumpWidget(
wrap(
IntraDateNav(
model: model,
onPreviousDay: () => prev++,
onNextDay: () => next++,
onDatePicked: (_) {},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_intra_prev')));
await tester.tap(find.byKey(const Key('insights_intra_next')));
expect(prev, 1);
expect(next, 1);
expect(find.text('8 ноября'), findsOneWidget);
});
testWidgets('«следующий день» отключён, когда выбран сегодняшний день', (
tester,
) async {
var next = 0;
await tester.pumpWidget(
wrap(
IntraDateNav(
model: model.copyWith(isToday: true, dateLabel: 'Сегодня'),
onPreviousDay: () {},
onNextDay: () => next++,
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('insights_intra_next')),
warnIfMissed: false,
);
expect(next, 0);
});
testWidgets(
'выбор даты: календарь ограничен maxDate и возвращает выбранный день',
(tester) async {
DateTime? picked;
await tester.pumpWidget(
wrap(
IntraDateNav(
model: model,
onPreviousDay: () {},
onNextDay: () {},
onDatePicked: (date) => picked = date,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_intra_pick')));
await tester.pumpAndSettle();
final dialog = tester.widget<CalendarDatePicker>(
find.byType(CalendarDatePicker),
);
expect(dialog.lastDate, DateTime(2026, 11, 9));
expect(dialog.initialDate, DateTime(2026, 11, 8));
await tester.tap(find.text('5'));
await tester.tap(
find.text('OK').evaluate().isNotEmpty
? find.text('OK')
: find.text('ОК'),
);
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 11, 5));
},
);
});
}