feat(app): страница здоровья вместо разрозненных /sync и data quality
/sync и блок data quality на дашборде сведены в один /health с двумя вкладками (Источники, Качество данных); дашборд ссылается на неё вместо собственного bottom sheet. Список находок вынесен в общий виджет DataQualityList, чтобы не дублировать рендер.
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:fintracker_api/fintracker_api.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
|
||||||
|
/// Maps a `/metrics/data-quality` row's severity string to a color. Shared by
|
||||||
|
/// the Здоровье page's findings list and the dashboard's summary chip so the
|
||||||
|
/// two surfaces never disagree on what a severity looks like.
|
||||||
|
Color severityColor(String severity) {
|
||||||
|
final s = severity.toLowerCase();
|
||||||
|
if (s.contains('crit') || s.contains('err')) return const Color(0xFFD03B3B);
|
||||||
|
if (s.contains('warn')) return const Color(0xFFFAB219);
|
||||||
|
if (s.contains('info') || s.contains('low')) return const Color(0xFF0CA30C);
|
||||||
|
return const Color(0xFFEC835A);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders `DataQualityRow`s as a colored-dot list: one implementation used
|
||||||
|
/// by both the Здоровье page and (if reused) the dashboard's quick peek.
|
||||||
|
class DataQualityList extends StatelessWidget {
|
||||||
|
const DataQualityList({required this.rows, super.key});
|
||||||
|
|
||||||
|
final List<DataQualityRow> rows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return const EmptyState(
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
message: 'Проблем не найдено.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [for (final row in rows) _DataQualityTile(row: row)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DataQualityTile extends StatelessWidget {
|
||||||
|
const _DataQualityTile({required this.row});
|
||||||
|
|
||||||
|
final DataQualityRow row;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 4, right: 10),
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(color: severityColor(row.severity), shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('${row.checkName} (${row.count})',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(row.detail),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
import '../../core/api/api_client.dart';
|
import '../../core/api/api_client.dart';
|
||||||
import '../../core/auth/auth_controller.dart';
|
import '../../core/auth/auth_controller.dart';
|
||||||
|
import '../../core/cache/cached.dart';
|
||||||
import '../../core/widgets/async_value_view.dart';
|
import '../../core/widgets/async_value_view.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
import '../home/providers.dart' show dataQualityProvider;
|
||||||
|
import 'data_quality_list.dart';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
|
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
|
||||||
|
|
||||||
@@ -25,16 +28,24 @@ final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((ref) asyn
|
|||||||
|
|
||||||
const _pollInterval = Duration(seconds: 10);
|
const _pollInterval = Duration(seconds: 10);
|
||||||
|
|
||||||
/// Синк: source statuses with a manual trigger per source, and a log of the
|
/// Здоровье: the one screen that answers "is my data OK right now" — source
|
||||||
/// last 20 runs. Polls `/sync/status` every 10 s while this page is mounted.
|
/// sync status with a manual trigger per source, a log of the last 20 runs,
|
||||||
class SyncPage extends ConsumerStatefulWidget {
|
/// and the data-quality findings, as two tabs of one page. Polls
|
||||||
const SyncPage({super.key});
|
/// `/sync/status` every 10 s while this page is mounted, regardless of which
|
||||||
|
/// tab is showing.
|
||||||
|
class HealthPage extends ConsumerStatefulWidget {
|
||||||
|
const HealthPage({this.initialTab = 0, super.key});
|
||||||
|
|
||||||
|
/// 0 = «Источники», 1 = «Качество данных». Lets the dashboard's data-quality
|
||||||
|
/// chip deep-link straight to the findings tab instead of always opening on
|
||||||
|
/// sources.
|
||||||
|
final int initialTab;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<SyncPage> createState() => _SyncPageState();
|
ConsumerState<HealthPage> createState() => _HealthPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SyncPageState extends ConsumerState<SyncPage> {
|
class _HealthPageState extends ConsumerState<HealthPage> {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -55,6 +66,7 @@ class _SyncPageState extends ConsumerState<SyncPage> {
|
|||||||
Future<void> _refreshAll() async {
|
Future<void> _refreshAll() async {
|
||||||
ref.invalidate(syncStatusProvider);
|
ref.invalidate(syncStatusProvider);
|
||||||
ref.invalidate(syncRunsProvider);
|
ref.invalidate(syncRunsProvider);
|
||||||
|
ref.invalidate(dataQualityProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _trigger(String source) async {
|
Future<void> _trigger(String source) async {
|
||||||
@@ -71,12 +83,15 @@ class _SyncPageState extends ConsumerState<SyncPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final status = ref.watch(syncStatusProvider);
|
final dataQuality = ref.watch(dataQualityProvider);
|
||||||
final runs = ref.watch(syncRunsProvider);
|
final issueCount = dataQuality.valueOrNull?.data.length;
|
||||||
|
|
||||||
return Scaffold(
|
return DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
initialIndex: widget.initialTab,
|
||||||
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Синк'),
|
title: const Text('Здоровье'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Обновить',
|
tooltip: 'Обновить',
|
||||||
@@ -84,9 +99,42 @@ class _SyncPageState extends ConsumerState<SyncPage> {
|
|||||||
onPressed: _refreshAll,
|
onPressed: _refreshAll,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
bottom: TabBar(
|
||||||
|
tabs: [
|
||||||
|
const Tab(icon: Icon(Icons.cable_outlined), text: 'Источники'),
|
||||||
|
Tab(
|
||||||
|
icon: const Icon(Icons.fact_check_outlined),
|
||||||
|
text: issueCount == null || issueCount == 0
|
||||||
|
? 'Качество данных'
|
||||||
|
: 'Качество данных ($issueCount)',
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
],
|
||||||
onRefresh: _refreshAll,
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
children: [
|
||||||
|
_SourcesTab(onRefresh: _refreshAll, onTrigger: _trigger),
|
||||||
|
_QualityTab(dataQuality: dataQuality, onRefresh: () async => ref.invalidate(dataQualityProvider)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SourcesTab extends ConsumerWidget {
|
||||||
|
const _SourcesTab({required this.onRefresh, required this.onTrigger});
|
||||||
|
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
final Future<void> Function(String source) onTrigger;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final status = ref.watch(syncStatusProvider);
|
||||||
|
final runs = ref.watch(syncRunsProvider);
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: onRefresh,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
@@ -101,7 +149,7 @@ class _SyncPageState extends ConsumerState<SyncPage> {
|
|||||||
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
|
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
|
||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
children: [for (final s in rows) _SourceCard(source: s, onTrigger: _trigger)],
|
children: [for (final s in rows) _SourceCard(source: s, onTrigger: onTrigger)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -116,6 +164,28 @@ class _SyncPageState extends ConsumerState<SyncPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityTab extends StatelessWidget {
|
||||||
|
const _QualityTab({required this.dataQuality, required this.onRefresh});
|
||||||
|
|
||||||
|
final AsyncValue<Cached<List<DataQualityRow>>> dataQuality;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
AsyncValueView(
|
||||||
|
value: dataQuality,
|
||||||
|
data: (cached) => DataQualityList(rows: cached.data),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,18 +15,11 @@ import '../../core/widgets/empty_state.dart';
|
|||||||
import '../../core/widgets/money_text.dart';
|
import '../../core/widgets/money_text.dart';
|
||||||
import '../../core/widgets/stale_banner.dart';
|
import '../../core/widgets/stale_banner.dart';
|
||||||
import '../../core/api/api_client.dart';
|
import '../../core/api/api_client.dart';
|
||||||
|
import '../health/data_quality_list.dart' show severityColor;
|
||||||
import 'providers.dart';
|
import 'providers.dart';
|
||||||
|
|
||||||
double _d(String s) => Decimal.parse(s).toDouble();
|
double _d(String s) => Decimal.parse(s).toDouble();
|
||||||
|
|
||||||
Color _severityColor(String severity) {
|
|
||||||
final s = severity.toLowerCase();
|
|
||||||
if (s.contains('crit') || s.contains('err')) return const Color(0xFFD03B3B);
|
|
||||||
if (s.contains('warn')) return const Color(0xFFFAB219);
|
|
||||||
if (s.contains('info') || s.contains('low')) return const Color(0xFF0CA30C);
|
|
||||||
return const Color(0xFFEC835A);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обзор: the dashboard landing page — net worth, this month's cashflow,
|
/// Обзор: the dashboard landing page — net worth, this month's cashflow,
|
||||||
/// runway, a net worth line chart, a 12-month income/expense bar chart, and
|
/// runway, a net worth line chart, a 12-month income/expense bar chart, and
|
||||||
/// a data-quality summary linking to the findings.
|
/// a data-quality summary linking to the findings.
|
||||||
@@ -53,53 +46,6 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showDataQuality(List<DataQualityRow> rows) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('Качество данных', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (rows.isEmpty) const Text('Проблем не найдено.'),
|
|
||||||
for (final row in rows)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(top: 4, right: 8),
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
decoration:
|
|
||||||
BoxDecoration(color: _severityColor(row.severity), shape: BoxShape.circle),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('${row.checkName} (${row.count})',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall),
|
|
||||||
Text(row.detail),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final breakdown = ref.watch(netWorthBreakdownProvider);
|
final breakdown = ref.watch(netWorthBreakdownProvider);
|
||||||
@@ -166,10 +112,12 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
avatar: Icon(
|
avatar: Icon(
|
||||||
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: rows.isEmpty ? Colors.green : _severityColor(rows.first.severity),
|
color: rows.isEmpty ? Colors.green : severityColor(rows.first.severity),
|
||||||
),
|
),
|
||||||
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
||||||
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
|
// Health page renders the same findings via DataQualityList — no
|
||||||
|
// second implementation of this list here.
|
||||||
|
onPressed: rows.isEmpty ? null : () => context.go('/health?tab=quality'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -249,9 +197,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: () => context.go('/sync'),
|
onPressed: () => context.go('/health'),
|
||||||
icon: const Icon(Icons.sync, size: 16),
|
icon: const Icon(Icons.monitor_heart_outlined, size: 16),
|
||||||
label: const Text('Синхронизация'),
|
label: const Text('Здоровье'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const _destinations = [
|
|||||||
_Destination(
|
_Destination(
|
||||||
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'),
|
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'),
|
||||||
_Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'),
|
_Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'),
|
||||||
_Destination('/sync', Icons.sync_outlined, Icons.sync, 'Синк'),
|
_Destination('/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье'),
|
||||||
_Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'),
|
_Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -8,6 +8,7 @@ import 'features/cashflow/cashflow_page.dart';
|
|||||||
import 'features/categories/categories_page.dart';
|
import 'features/categories/categories_page.dart';
|
||||||
import 'features/events/events_page.dart';
|
import 'features/events/events_page.dart';
|
||||||
import 'features/goals/goals_page.dart';
|
import 'features/goals/goals_page.dart';
|
||||||
|
import 'features/health/health_page.dart';
|
||||||
import 'features/home/home_page.dart';
|
import 'features/home/home_page.dart';
|
||||||
import 'features/income/income_page.dart';
|
import 'features/income/income_page.dart';
|
||||||
import 'features/imports/import_preview_page.dart';
|
import 'features/imports/import_preview_page.dart';
|
||||||
@@ -21,7 +22,6 @@ import 'features/rules/rules_page.dart';
|
|||||||
import 'features/settings/settings_page.dart';
|
import 'features/settings/settings_page.dart';
|
||||||
import 'features/shell/analytics_page.dart';
|
import 'features/shell/analytics_page.dart';
|
||||||
import 'features/shell/app_shell.dart';
|
import 'features/shell/app_shell.dart';
|
||||||
import 'features/sync/sync_page.dart';
|
|
||||||
import 'features/tax/tax_page.dart';
|
import 'features/tax/tax_page.dart';
|
||||||
import 'features/transactions/transactions_page.dart';
|
import 'features/transactions/transactions_page.dart';
|
||||||
|
|
||||||
@@ -82,7 +82,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(path: '/transactions', builder: (_, _) => const TransactionsPage()),
|
GoRoute(path: '/transactions', builder: (_, _) => const TransactionsPage()),
|
||||||
GoRoute(path: '/rules', builder: (_, _) => const RulesPage()),
|
GoRoute(path: '/rules', builder: (_, _) => const RulesPage()),
|
||||||
GoRoute(path: '/sync', builder: (_, _) => const SyncPage()),
|
GoRoute(
|
||||||
|
path: '/health',
|
||||||
|
builder: (_, state) => HealthPage(
|
||||||
|
initialTab: state.uri.queryParameters['tab'] == 'quality' ? 1 : 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:fintracker_api/fintracker_api.dart';
|
||||||
|
import 'package:fintracker_app/core/cache/cached.dart';
|
||||||
|
import 'package:fintracker_app/features/health/health_page.dart';
|
||||||
|
import 'package:fintracker_app/features/home/providers.dart' show dataQualityProvider;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
SourceStatus _source(String name) => SourceStatus(
|
||||||
|
source_: name,
|
||||||
|
lastRunStatus: RunStatus.ok,
|
||||||
|
lastRunAt: DateTime(2026, 9, 18, 10),
|
||||||
|
lastSuccessAt: DateTime(2026, 9, 18, 10),
|
||||||
|
cursor: null,
|
||||||
|
queued: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
DataQualityRow _issue() => DataQualityRow(
|
||||||
|
id: 1,
|
||||||
|
ref: null,
|
||||||
|
severity: 'warn',
|
||||||
|
checkName: 'missing_fx',
|
||||||
|
count: 3,
|
||||||
|
detail: 'Курс не найден для 3 операций.',
|
||||||
|
computedAt: DateTime(2026, 9, 18, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows sources on the first tab and findings on the second', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
syncStatusProvider.overrideWith((ref) async => [_source('zenmoney')]),
|
||||||
|
syncRunsProvider.overrideWith((ref) async => const []),
|
||||||
|
dataQualityProvider.overrideWith((ref) => Cached([_issue()])),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: HealthPage()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Starts on «Источники»: the source card is visible, the findings text isn't.
|
||||||
|
expect(find.text('zenmoney'), findsOneWidget);
|
||||||
|
expect(find.textContaining('missing_fx'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Качество данных (1)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('missing_fx'), findsOneWidget);
|
||||||
|
expect(find.text('Курс не найден для 3 операций.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('opens directly on the findings tab when initialTab is 1', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
syncStatusProvider.overrideWith((ref) async => const []),
|
||||||
|
syncRunsProvider.overrideWith((ref) async => const []),
|
||||||
|
dataQualityProvider.overrideWith((ref) => Cached([_issue()])),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: HealthPage(initialTab: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('missing_fx'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('empty findings show the no-issues empty state', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
syncStatusProvider.overrideWith((ref) async => const []),
|
||||||
|
syncRunsProvider.overrideWith((ref) async => const []),
|
||||||
|
dataQualityProvider.overrideWith((ref) => const Cached(<DataQualityRow>[])),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: HealthPage(initialTab: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Проблем не найдено.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user