From 633fc5d9da5e0f842d251b07d1eec0dce05419a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Wed, 27 May 2026 21:20:19 +0200 Subject: [PATCH] fix: show full discrepancy details in account list (#296) (#301) --- lib/ui/screens/account_list_screen.dart | 34 ++++++++++++++++- test/widget/account_list_screen_test.dart | 46 +++++++++++++++++++++++ test/widget/helpers.dart | 16 +++++--- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index d5e88a5..5e7e0b4 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -124,7 +125,6 @@ class _AccountTile extends ConsumerWidget { if (h == null) return const Text('Sync health: Not verified yet'); final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; return Row( - mainAxisSize: MainAxisSize.min, children: [ const Text('Sync health: '), Icon( @@ -133,7 +133,13 @@ class _AccountTile extends ConsumerWidget { color: h.isHealthy ? Colors.green : Colors.orange, ), const SizedBox(width: 4), - Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'), + Flexible( + child: Text( + h.isHealthy + ? 'Healthy' + : _formatDiscrepancies(h.discrepancySummary), + ), + ), Text(' ($date)', style: const TextStyle(fontSize: 10)), ], ); @@ -293,6 +299,30 @@ class _AccountTile extends ConsumerWidget { } } +String _formatDiscrepancies(String? summary) { + if (summary == null) return 'Discrepancies found'; + try { + final decoded = jsonDecode(summary) as Map; + var missingLocally = 0; + var missingOnServer = 0; + var flagMismatches = 0; + for (final v in decoded.values) { + final m = v as Map; + missingLocally += (m['missingLocally'] as int? ?? 0); + missingOnServer += (m['missingOnServer'] as int? ?? 0); + flagMismatches += (m['flagMismatches'] as int? ?? 0); + } + final parts = []; + if (missingLocally > 0) parts.add('missing locally: $missingLocally'); + if (missingOnServer > 0) parts.add('missing on server: $missingOnServer'); + if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches'); + if (parts.isEmpty) return 'Discrepancies found'; + return 'Discrepancies found (${parts.join(', ')})'; + } catch (_) { + return 'Discrepancies found'; + } +} + class _OnboardingView extends StatelessWidget { const _OnboardingView(); diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index 638f675..ba52d33 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow; import 'helpers.dart'; @@ -206,5 +207,50 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('sharedinbox.de'), findsOneWidget); }); + + testWidgets('shows Healthy when sync health is healthy', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts', + overrides: baseOverrides( + accounts: [kTestAccount], + syncHealth: SyncHealthRow( + accountId: kTestAccount.id, + lastVerifiedAt: DateTime(2024, 6), + isHealthy: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Healthy'), findsOneWidget); + }); + + testWidgets( + 'shows discrepancy details when sync health has discrepancies', + (tester) async { + const summary = + '{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}'; + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts', + overrides: baseOverrides( + accounts: [kTestAccount], + syncHealth: SyncHealthRow( + accountId: kTestAccount.id, + lastVerifiedAt: DateTime(2024, 6), + isHealthy: false, + discrepancySummary: summary, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('missing locally: 3'), findsOneWidget); + expect(find.textContaining('flag mismatches: 1'), findsOneWidget); + }, + ); }); } diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index aa96deb..cc1e04b 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -25,6 +25,7 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/share_encryption_service.dart'; +import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; @@ -505,13 +506,12 @@ Widget buildApp({ return ProviderScope( // Defaults come first so tests can override them via [overrides]. // - // syncHealthProvider and syncLogRepositoryProvider are backed by Drift - // StreamQueries. When a StreamProvider that wraps a Drift query is disposed, - // Drift schedules a Timer.run() for cache debouncing. Flutter's test - // framework then fails the test with "A Timer is still pending". Replacing - // these with simple synchronous streams avoids the pending-timer assertion. + // syncLogRepositoryProvider is backed by a Drift StreamQuery. When the + // provider is disposed, Drift schedules a Timer.run() for cache + // debouncing. Flutter's test framework then fails the test with "A Timer + // is still pending". Replacing it with a synchronous stream avoids this. + // syncHealthProvider has the same issue and is overridden in baseOverrides. overrides: [ - syncHealthProvider.overrideWith((ref, _) => Stream.value(null)), syncLogRepositoryProvider.overrideWithValue( const NoOpSyncLogRepository(), ), @@ -541,6 +541,7 @@ List baseOverrides({ Exception? connectionError, ShareKeyRepository? shareKeyRepository, bool hasStoredPassword = true, + SyncHealthRow? syncHealth, }) => [ accountRepositoryProvider.overrideWithValue( @@ -559,6 +560,9 @@ List baseOverrides({ shareKeyRepositoryProvider.overrideWithValue( shareKeyRepository ?? FakeShareKeyRepository(), ), + // syncHealthProvider is backed by a Drift StreamQuery; override with a + // plain stream to avoid "A Timer is still pending" in tests. + syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)), ]; // ---------------------------------------------------------------------------