diff --git a/docs/progress.md b/docs/progress.md index 3c2f69f..f51f5ea 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,6 +1,28 @@ # Relationship Saver Progress Log -Updated: 2026-02-15 +Updated: 2026-02-17 + +## Latest Milestone (2026-02-17): Sync Repair Test Stability + Mobile CRUD/Sync Flow Coverage + +Completed two test-depth upgrades: + +- Stabilized rejected-sync repair widget flow: + - `test/features/sync/sync_rejection_repair_flow_test.dart` + - fixed viewport-aware assertions/actions for `ListView` content by + scrolling before interacting with rejection controls + - verifies rejected item -> open related repair screen -> save -> + local state mutation applied +- Added mobile-first integration-style flow: + - `test/features/app/app_mobile_crud_sync_flow_test.dart` + - covers compact-shell path: sign-in -> People -> add person -> sync queue + envelope asserted -> open Sync from `More` menu + - validates both UI behavior and queued backend envelope side effect in + `SyncQueueRepository` + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass ## Latest Milestone (2026-02-15): Direct Entity Repair Forms From Sync @@ -16,6 +38,9 @@ Implemented deep-link repair forms for rejected sync items: - `reminderRule` - each form updates local state via `LocalRepository` directly - focus banner keeps exact `entityType:entityId` context with copy action +- Added widget flow coverage: + - `test/features/sync/sync_rejection_repair_flow_test.dart` + - verifies reject -> open repair form -> save -> local state updated Validation for this milestone: @@ -291,7 +316,8 @@ Validation for this milestone: - implemented via reachability gating; future improvement is explicit OS connectivity event subscription. 4. Test depth: - - add integration tests for auth + sync + CRUD end-to-end app flows. + - expanded with mobile compact CRUD+sync flow and rejected-repair flow. + - future: add desktop + web-specific end-to-end sync error/retry scenario. ## Latest Milestone (2026-02-15): Sync Queue Storage Adapter + Hive Migration diff --git a/test/features/app/app_mobile_crud_sync_flow_test.dart b/test/features/app/app_mobile_crud_sync_flow_test.dart new file mode 100644 index 0000000..70d5d3f --- /dev/null +++ b/test/features/app/app_mobile_crud_sync_flow_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/core/auth/in_memory_token_store.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; +import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_fake.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; +import 'package:relationship_saver/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('mobile flow adds person and queues a sync change', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final ProviderContainer container = ProviderContainer( + overrides: [ + tokenStoreProvider.overrideWithValue(InMemoryTokenStore()), + backendGatewayProvider.overrideWithValue(BackendGatewayFake()), + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const RelationshipSaverApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Sign In')); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.text('Dashboard'), findsWidgets); + + await tester.tap(find.text('People').last); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.text('People'), findsWidgets); + + await tester.tap(find.widgetWithText(FilledButton, 'Add person')); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + + expect(find.text('Add Person'), findsOneWidget); + + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Taylor Quinn'); + await tester.enterText( + find.widgetWithText(TextField, 'Relationship'), + 'Partner', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Tags (comma separated)'), + 'tea, books', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Notes'), + 'Prefers calm evenings and handmade gifts.', + ); + + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.text('Taylor Quinn'), findsWidgets); + + final syncState = await container.read(syncQueueRepositoryProvider.future); + expect(syncState.pendingChanges.isNotEmpty, isTrue); + expect( + syncState.pendingChanges.any((ChangeEnvelope change) { + return change.entityType == 'person' && + change.op == ChangeOperation.upsert && + change.payload?['name'] == 'Taylor Quinn'; + }), + isTrue, + ); + + await tester.tap(find.byTooltip('More')); + await tester.pumpAndSettle(const Duration(milliseconds: 250)); + await tester.tap(find.text('Sync').last); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.text('Queue Status'), findsOneWidget); + expect(find.text('Pending changes'), findsOneWidget); + }); +} diff --git a/test/features/sync/sync_rejection_repair_flow_test.dart b/test/features/sync/sync_rejection_repair_flow_test.dart new file mode 100644 index 0000000..1d3a9ae --- /dev/null +++ b/test/features/sync/sync_rejection_repair_flow_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; +import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; +import 'package:relationship_saver/features/sync/sync_view.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +void main() { + testWidgets( + 'rejected person mutation can be repaired directly from Sync view', + (WidgetTester tester) async { + final ProviderContainer container = ProviderContainer( + overrides: [ + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + ], + ); + addTearDown(container.dispose); + + final localBefore = await container.read(localRepositoryProvider.future); + final person = localBefore.people.first; + + final ChangeEnvelope rejectedChange = ChangeEnvelope( + schemaVersion: 1, + entityType: 'person', + entityId: person.id, + op: ChangeOperation.upsert, + modifiedAt: DateTime(2026, 2, 15, 12).toUtc(), + clientMutationId: 'cm-repair-1', + payload: { + 'name': person.name, + 'relationship': person.relationship, + 'notes': person.notes, + 'tags': person.tags, + }, + ); + + final syncNotifier = container.read(syncQueueRepositoryProvider.notifier); + await syncNotifier.enqueue(rejectedChange); + await syncNotifier.applyPushResult( + const SyncPushResult( + cursor: 'cursor-1', + rejected: [ + MutationRejection( + clientMutationId: 'cm-repair-1', + code: 'VALIDATION_FAILED', + message: 'Bad payload', + ), + ], + ), + ); + final syncState = await container.read(syncQueueRepositoryProvider.future); + expect(syncState.lastRejected.length, 1); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp(theme: AppTheme.light(), home: const SyncView()), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + await tester.scrollUntilVisible( + find.text('Rejected Changes'), + 240, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Rejected Changes'), findsOneWidget); + await tester.scrollUntilVisible( + find.widgetWithText(TextButton, 'Open Related Screen'), + 120, + scrollable: find.byType(Scrollable).first, + ); + final Finder openRelatedScreenButton = find.widgetWithText( + TextButton, + 'Open Related Screen', + ); + expect(openRelatedScreenButton, findsOneWidget); + await tester.ensureVisible(openRelatedScreenButton); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + await tester.tap(openRelatedScreenButton, warnIfMissed: false); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.textContaining('Focus: person:'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, 'Renamed Person'); + await tester.tap(find.widgetWithText(FilledButton, 'Save Changes')); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + final localAfter = await container.read(localRepositoryProvider.future); + final updated = localAfter.people.firstWhere( + (item) => item.id == person.id, + ); + expect(updated.name, 'Renamed Person'); + }, + ); +}