Add mobile CRUD-sync flow coverage and stabilize sync repair test
This commit is contained in:
+28
-2
@@ -1,6 +1,28 @@
|
|||||||
# Relationship Saver Progress Log
|
# 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
|
## 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`
|
- `reminderRule`
|
||||||
- each form updates local state via `LocalRepository` directly
|
- each form updates local state via `LocalRepository` directly
|
||||||
- focus banner keeps exact `entityType:entityId` context with copy action
|
- 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:
|
Validation for this milestone:
|
||||||
|
|
||||||
@@ -291,7 +316,8 @@ Validation for this milestone:
|
|||||||
- implemented via reachability gating; future improvement is explicit OS
|
- implemented via reachability gating; future improvement is explicit OS
|
||||||
connectivity event subscription.
|
connectivity event subscription.
|
||||||
4. Test depth:
|
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
|
## Latest Milestone (2026-02-15): Sync Queue Storage Adapter + Hive Migration
|
||||||
|
|
||||||
|
|||||||
@@ -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(<String, Object>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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: <String, dynamic>{
|
||||||
|
'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>[
|
||||||
|
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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user