f655adfbea
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
869 lines
28 KiB
Dart
869 lines
28 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:relationship_saver/features/local/local_models.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/reminders/scheduling/reminder_scheduler.dart';
|
|
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
|
|
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
|
|
import 'package:relationship_saver/features/share_intake/share_payload_parser.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:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
|
});
|
|
|
|
test('seeds local data on first load', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final state = await container.read(localRepositoryProvider.future);
|
|
|
|
expect(state.people, isNotEmpty);
|
|
expect(state.moments, isNotEmpty);
|
|
expect(state.tasks, isNotEmpty);
|
|
});
|
|
|
|
test('adds and removes person with persistence state update', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final initial = await container.read(localRepositoryProvider.future);
|
|
final int initialCount = initial.people.length;
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Test Person',
|
|
relationship: 'Friend',
|
|
notes: 'test',
|
|
tags: <String>['alpha'],
|
|
location: 'Berlin',
|
|
);
|
|
|
|
final afterAdd = await container.read(localRepositoryProvider.future);
|
|
expect(afterAdd.people.length, initialCount + 1);
|
|
expect(afterAdd.people.first.location, 'Berlin');
|
|
|
|
final String insertedId = afterAdd.people.first.id;
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.deletePerson(insertedId);
|
|
|
|
final afterDelete = await container.read(localRepositoryProvider.future);
|
|
expect(afterDelete.people.length, initialCount);
|
|
});
|
|
|
|
test('adds and removes capture moments', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final state = await container.read(localRepositoryProvider.future);
|
|
final String personId = state.people.first.id;
|
|
final int beforeCount = state.moments.length;
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addMoment(personId: personId, summary: 'This is a new capture moment');
|
|
|
|
final withMoment = await container.read(localRepositoryProvider.future);
|
|
expect(withMoment.moments.length, beforeCount + 1);
|
|
|
|
final String momentId = withMoment.moments.first.id;
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.deleteMoment(momentId);
|
|
|
|
final afterDelete = await container.read(localRepositoryProvider.future);
|
|
expect(afterDelete.moments.length, beforeCount);
|
|
});
|
|
|
|
test('upserts inferred preference signals and updates status', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final LocalDataState state = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final String personId = state.people.first.id;
|
|
|
|
final PersonPreferenceSignal first = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.upsertInferredPreferenceSignalObservation(
|
|
personId: personId,
|
|
key: 'drink:tea',
|
|
label: 'Tea',
|
|
category: 'drink',
|
|
polarity: PreferenceSignalPolarity.like,
|
|
confidence: 0.7,
|
|
sourceApp: 'whatsapp',
|
|
evidenceMessageId: 'sm-1',
|
|
evidenceSnippet: 'I prefer tea over coffee in the evening.',
|
|
);
|
|
|
|
final PersonPreferenceSignal second = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.upsertInferredPreferenceSignalObservation(
|
|
personId: personId,
|
|
key: 'drink:tea',
|
|
label: 'Tea',
|
|
category: 'drink',
|
|
polarity: PreferenceSignalPolarity.like,
|
|
confidence: 0.9,
|
|
sourceApp: 'imessage',
|
|
evidenceMessageId: 'sm-2',
|
|
evidenceSnippet: 'Tea sounds perfect after dinner.',
|
|
);
|
|
|
|
final LocalDataState afterUpsert = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final PersonPreferenceSignal signal = afterUpsert.preferenceSignals
|
|
.firstWhere((PersonPreferenceSignal item) => item.id == first.id);
|
|
|
|
expect(first.id, second.id);
|
|
expect(signal.personId, personId);
|
|
expect(signal.occurrenceCount, 2);
|
|
expect(signal.status, PreferenceSignalStatus.inferred);
|
|
expect(signal.confidence, closeTo(0.8, 0.001));
|
|
expect(signal.sourceApps, containsAll(<String>['whatsapp', 'imessage']));
|
|
expect(signal.evidenceMessageIds, containsAll(<String>['sm-1', 'sm-2']));
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.confirmPreferenceSignal(signal.id);
|
|
final LocalDataState afterConfirm = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(
|
|
afterConfirm.preferenceSignals
|
|
.firstWhere((PersonPreferenceSignal item) => item.id == signal.id)
|
|
.status,
|
|
PreferenceSignalStatus.confirmed,
|
|
);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.dismissPreferenceSignal(signal.id);
|
|
final LocalDataState afterDismiss = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(
|
|
afterDismiss.preferenceSignals
|
|
.firstWhere((PersonPreferenceSignal item) => item.id == signal.id)
|
|
.status,
|
|
PreferenceSignalStatus.dismissed,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages',
|
|
() async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final LocalDataState before = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
|
|
final SharedMessageIngestResult first = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Taylor Quinn',
|
|
sourceUserId: 'wa:taylor quinn',
|
|
messageText: 'Can we do coffee this week?',
|
|
),
|
|
);
|
|
|
|
final LocalDataState afterFirst = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(first.createdProfile, isTrue);
|
|
expect(afterFirst.people.length, before.people.length + 1);
|
|
final SourceProfileLink createdLink = afterFirst.sourceLinks.firstWhere(
|
|
(SourceProfileLink link) => link.profileId == first.profileId,
|
|
);
|
|
expect(createdLink.sourceFingerprint, 'whatsapp|u:wa:taylor quinn');
|
|
expect(
|
|
afterFirst.sourceLinks.any(
|
|
(SourceProfileLink link) => link.profileId == first.profileId,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
afterFirst.moments.any(
|
|
(RelationshipMoment moment) =>
|
|
moment.personId == first.profileId && moment.type == 'whatsapp',
|
|
),
|
|
isTrue,
|
|
);
|
|
|
|
final SharedMessageIngestResult second = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Taylor Quinn',
|
|
sourceUserId: 'wa:taylor quinn',
|
|
messageText: 'Lets plan Sunday brunch too.',
|
|
),
|
|
);
|
|
|
|
final LocalDataState afterSecond = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(second.createdProfile, isFalse);
|
|
expect(second.profileId, first.profileId);
|
|
expect(afterSecond.people.length, afterFirst.people.length);
|
|
expect(
|
|
afterSecond.sharedMessages.length,
|
|
afterFirst.sharedMessages.length + 1,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'ingestSharedMessage extracts chat-derived preference signals',
|
|
() async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(localRepositoryProvider.future);
|
|
|
|
final SharedMessageIngestResult result = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Nora Diaz',
|
|
sourceUserId: 'wa:nora_001',
|
|
messageText:
|
|
"I prefer tea over coffee and I don't like loud clubs.",
|
|
),
|
|
);
|
|
|
|
final LocalDataState after = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final List<PersonPreferenceSignal> signals = after.preferenceSignals
|
|
.where(
|
|
(PersonPreferenceSignal signal) =>
|
|
signal.personId == result.profileId,
|
|
)
|
|
.toList(growable: false);
|
|
|
|
expect(signals, isNotEmpty);
|
|
expect(
|
|
signals.any(
|
|
(PersonPreferenceSignal signal) =>
|
|
signal.key == 'drink:tea' &&
|
|
signal.polarity == PreferenceSignalPolarity.like &&
|
|
signal.status == PreferenceSignalStatus.inferred,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
signals.any(
|
|
(PersonPreferenceSignal signal) =>
|
|
signal.key == 'drink:coffee' &&
|
|
signal.polarity == PreferenceSignalPolarity.dislike,
|
|
),
|
|
isTrue,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'saveSharedPayloadToInbox then resolve persists structured fact draft',
|
|
() async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final LocalDataState state = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final String personId = state.people.first.id;
|
|
final SharedPayload payload = SharePayloadParser.parse(
|
|
rawText: 'She loves vegan sushi and rooftop dinners.',
|
|
sourceApp: 'share_sheet',
|
|
platform: 'android',
|
|
);
|
|
|
|
final SharedMessageIngestResult queued = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.saveSharedPayloadToInbox(
|
|
payload: payload,
|
|
draft: const CapturedFactDraft(
|
|
type: CapturedFactType.like,
|
|
text: 'Vegan sushi and rooftop dinners',
|
|
),
|
|
);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.resolveSharedInboxToExistingProfile(
|
|
inboxEntryId: queued.inboxEntryId!,
|
|
profileId: personId,
|
|
draft: const CapturedFactDraft(
|
|
type: CapturedFactType.like,
|
|
text: 'Vegan sushi and rooftop dinners',
|
|
),
|
|
);
|
|
|
|
final LocalDataState after = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(
|
|
after.personFacts.any(
|
|
(PersonFact fact) =>
|
|
fact.personId == personId &&
|
|
fact.type == CapturedFactType.like &&
|
|
fact.text.contains('Vegan sushi'),
|
|
),
|
|
isTrue,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'captureSharedPayloadByCreatingProfile can persist important date',
|
|
() async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(localRepositoryProvider.future);
|
|
final SharedPayload payload = SharePayloadParser.parse(
|
|
rawText: 'Anniversary dinner https://example.com',
|
|
sourceApp: 'share_sheet',
|
|
platform: 'ios',
|
|
);
|
|
|
|
final SharedMessageIngestResult result = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.captureSharedPayloadByCreatingProfile(
|
|
payload: payload,
|
|
name: 'Robin Vale',
|
|
relationship: 'Partner',
|
|
aliases: const <String>['RV'],
|
|
draft: CapturedFactDraft(
|
|
type: CapturedFactType.importantDate,
|
|
text: 'Anniversary',
|
|
label: 'Anniversary dinner',
|
|
dateValue: DateTime(2026, 9, 14),
|
|
),
|
|
);
|
|
|
|
final LocalDataState after = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final PersonProfile created = after.people.firstWhere(
|
|
(PersonProfile person) => person.id == result.profileId,
|
|
);
|
|
|
|
expect(created.aliases, contains('RV'));
|
|
expect(
|
|
after.importantDates.any(
|
|
(PersonImportantDate value) =>
|
|
value.personId == created.id &&
|
|
value.label == 'Anniversary dinner',
|
|
),
|
|
isTrue,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
|
|
() async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(localRepositoryProvider.future);
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Marek Havel',
|
|
relationship: 'Friend',
|
|
notes: '',
|
|
tags: const <String>[],
|
|
);
|
|
final LocalDataState peopleState = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final String marekId = peopleState.people
|
|
.firstWhere((PersonProfile person) => person.name == 'Marek Havel')
|
|
.id;
|
|
|
|
final SharedMessageIngestResult queued = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Marek Havl',
|
|
sourceUserId: 'wa:marek_882',
|
|
messageText: 'Saw this and thought of you.',
|
|
),
|
|
);
|
|
LocalDataState afterQueue = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(queued.isQueuedForResolution, isTrue);
|
|
expect(
|
|
afterQueue.sharedInbox.first.reason,
|
|
SharedInboxReason.nearProfileConflict,
|
|
);
|
|
expect(
|
|
afterQueue.sharedInbox.first.candidateProfileIds,
|
|
contains(marekId),
|
|
);
|
|
|
|
final SharedMessageIngestResult resolved = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.resolveSharedInboxToExistingProfile(
|
|
inboxEntryId: queued.inboxEntryId!,
|
|
profileId: marekId,
|
|
);
|
|
expect(resolved.profileId, marekId);
|
|
|
|
final SharedMessageIngestResult followUp = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Alias Name',
|
|
sourceUserId: 'wa:marek_882',
|
|
messageText: 'Second follow-up message.',
|
|
),
|
|
);
|
|
afterQueue = await container.read(localRepositoryProvider.future);
|
|
expect(followUp.isQueuedForResolution, isFalse);
|
|
expect(followUp.profileId, marekId);
|
|
expect(afterQueue.sharedInbox, isEmpty);
|
|
expect(
|
|
afterQueue.sourceLinks.any(
|
|
(SourceProfileLink link) =>
|
|
link.profileId == marekId &&
|
|
link.sourceFingerprint == 'whatsapp|u:wa:marek_882',
|
|
),
|
|
isTrue,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'queues ambiguous shared message into inbox for manual resolution',
|
|
() async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final LocalDataState before = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Alex Morgan',
|
|
relationship: 'Friend',
|
|
notes: '',
|
|
tags: const <String>[],
|
|
);
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Alex Morgan',
|
|
relationship: 'Cousin',
|
|
notes: '',
|
|
tags: const <String>[],
|
|
);
|
|
|
|
final SharedMessageIngestResult result = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Alex Morgan',
|
|
messageText: 'Can we sync up tomorrow?',
|
|
),
|
|
);
|
|
|
|
final LocalDataState after = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(result.isQueuedForResolution, isTrue);
|
|
expect(result.inboxEntryId, isNotNull);
|
|
expect(after.sharedInbox.length, 1);
|
|
expect(
|
|
after.sharedInbox.first.reason,
|
|
SharedInboxReason.ambiguousProfileMatch,
|
|
);
|
|
expect(after.moments.length, before.moments.length);
|
|
},
|
|
);
|
|
|
|
test('resolves inbox item to existing profile', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(localRepositoryProvider.future);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Alex Morgan',
|
|
relationship: 'Friend',
|
|
notes: '',
|
|
tags: const <String>[],
|
|
);
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Alex Morgan',
|
|
relationship: 'Cousin',
|
|
notes: '',
|
|
tags: const <String>[],
|
|
);
|
|
|
|
final SharedMessageIngestResult queued = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Alex Morgan',
|
|
messageText: 'Did you see the offer?',
|
|
),
|
|
);
|
|
final LocalDataState pending = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final String profileId = pending.people.first.id;
|
|
|
|
final SharedMessageIngestResult resolved = await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.resolveSharedInboxToExistingProfile(
|
|
inboxEntryId: queued.inboxEntryId!,
|
|
profileId: profileId,
|
|
);
|
|
|
|
final LocalDataState after = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(resolved.isQueuedForResolution, isFalse);
|
|
expect(resolved.profileId, profileId);
|
|
expect(after.sharedInbox, isEmpty);
|
|
expect(
|
|
after.moments.any(
|
|
(RelationshipMoment moment) => moment.personId == profileId,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(after.sharedMessages.first.resolvedAutomatically, isFalse);
|
|
});
|
|
|
|
test(
|
|
'mergePersonProfiles rebinds related records to target profile',
|
|
() async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(localRepositoryProvider.future);
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Ava Hart',
|
|
relationship: 'Partner',
|
|
notes: 'Primary notes',
|
|
tags: const <String>['coffee'],
|
|
location: 'Austin',
|
|
);
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addPerson(
|
|
name: 'Ava H.',
|
|
relationship: 'Friend',
|
|
notes: 'Secondary notes',
|
|
tags: const <String>['books'],
|
|
);
|
|
|
|
LocalDataState state = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final String targetId = state.people
|
|
.firstWhere((PersonProfile person) => person.name == 'Ava Hart')
|
|
.id;
|
|
final String sourceId = state.people
|
|
.firstWhere((PersonProfile person) => person.name == 'Ava H.')
|
|
.id;
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addMoment(personId: sourceId, summary: 'Source profile moment');
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addIdea(
|
|
type: IdeaType.gift,
|
|
title: 'Source profile idea',
|
|
details: 'idea',
|
|
personId: sourceId,
|
|
);
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addReminder(
|
|
title: 'Source profile reminder',
|
|
cadence: ReminderCadence.weekly,
|
|
nextAt: DateTime.now().add(const Duration(days: 2)),
|
|
personId: sourceId,
|
|
);
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.ingestSharedMessage(
|
|
const SharedMessageIngestInput(
|
|
sourceApp: 'whatsapp',
|
|
sourceDisplayName: 'Ava H.',
|
|
sourceUserId: 'wa:ava_h',
|
|
messageText: 'Source-linked shared message',
|
|
),
|
|
);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.mergePersonProfiles(
|
|
sourcePersonId: sourceId,
|
|
targetPersonId: targetId,
|
|
);
|
|
|
|
state = await container.read(localRepositoryProvider.future);
|
|
expect(
|
|
state.people.any((PersonProfile person) => person.id == sourceId),
|
|
isFalse,
|
|
);
|
|
|
|
final PersonProfile merged = state.people.firstWhere(
|
|
(PersonProfile person) => person.id == targetId,
|
|
);
|
|
expect(merged.tags, containsAll(<String>['coffee', 'books']));
|
|
expect(merged.notes, contains('Primary notes'));
|
|
expect(merged.notes, contains('Secondary notes'));
|
|
expect(merged.location, 'Austin');
|
|
|
|
expect(
|
|
state.moments.any(
|
|
(RelationshipMoment moment) => moment.personId == sourceId,
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
state.ideas.any((RelationshipIdea idea) => idea.personId == sourceId),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
state.reminders.any(
|
|
(ReminderRule reminder) => reminder.personId == sourceId,
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
state.sourceLinks.any(
|
|
(SourceProfileLink link) =>
|
|
link.sourceUserId == 'wa:ava_h' && link.profileId == targetId,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
state.sharedMessages.any(
|
|
(SharedMessageEntry entry) =>
|
|
entry.sourceUserId == 'wa:ava_h' && entry.profileId == targetId,
|
|
),
|
|
isTrue,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('adds, archives, edits, and deletes ideas', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final LocalDataState state = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final String personId = state.people.first.id;
|
|
final int beforeCount = state.ideas.length;
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addIdea(
|
|
type: IdeaType.gift,
|
|
title: 'Test idea',
|
|
details: 'some details',
|
|
personId: personId,
|
|
);
|
|
|
|
final LocalDataState afterAdd = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(afterAdd.ideas.length, beforeCount + 1);
|
|
|
|
final RelationshipIdea inserted = afterAdd.ideas.first;
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.toggleIdeaArchived(inserted.id);
|
|
|
|
LocalDataState afterArchive = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(afterArchive.ideas.first.isArchived, isTrue);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.updateIdea(afterArchive.ideas.first.copyWith(title: 'Updated idea'));
|
|
|
|
afterArchive = await container.read(localRepositoryProvider.future);
|
|
expect(afterArchive.ideas.first.title, 'Updated idea');
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.deleteIdea(inserted.id);
|
|
|
|
final LocalDataState afterDelete = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(afterDelete.ideas.length, beforeCount);
|
|
});
|
|
|
|
test('adds, updates, toggles, and deletes reminders', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final LocalDataState state = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final String personId = state.people.first.id;
|
|
final int beforeCount = state.reminders.length;
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addReminder(
|
|
title: 'Test reminder',
|
|
cadence: ReminderCadence.weekly,
|
|
nextAt: DateTime.now().add(const Duration(days: 1)),
|
|
personId: personId,
|
|
);
|
|
|
|
LocalDataState afterAdd = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(afterAdd.reminders.length, beforeCount + 1);
|
|
|
|
final ReminderRule inserted = afterAdd.reminders.first;
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.toggleReminderEnabled(inserted.id);
|
|
afterAdd = await container.read(localRepositoryProvider.future);
|
|
expect(afterAdd.reminders.first.enabled, isFalse);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.updateReminder(
|
|
afterAdd.reminders.first.copyWith(title: 'Updated reminder'),
|
|
);
|
|
afterAdd = await container.read(localRepositoryProvider.future);
|
|
expect(afterAdd.reminders.first.title, 'Updated reminder');
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.deleteReminder(inserted.id);
|
|
|
|
final LocalDataState afterDelete = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(afterDelete.reminders.length, beforeCount);
|
|
});
|
|
|
|
test('dismissLocalSignal persists dismissed ids in local state', () async {
|
|
final ProviderContainer container = _createContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(localRepositoryProvider.future);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.dismissLocalSignal('local-checkin-p-ava-123');
|
|
|
|
final LocalDataState after = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
expect(after.dismissedSignalIds, contains('local-checkin-p-ava-123'));
|
|
});
|
|
|
|
test('reconciles reminder schedule after state changes', () async {
|
|
final RecordingReminderScheduler scheduler = RecordingReminderScheduler();
|
|
final ProviderContainer container = _createContainer(scheduler: scheduler);
|
|
addTearDown(container.dispose);
|
|
|
|
final LocalDataState initial = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
final int beforeCount = initial.reminders.length;
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.addReminder(
|
|
title: 'Scheduled reminder',
|
|
cadence: ReminderCadence.daily,
|
|
nextAt: DateTime.now().add(const Duration(hours: 4)),
|
|
);
|
|
final LocalDataState afterAdd = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
|
|
await container
|
|
.read(localRepositoryProvider.notifier)
|
|
.deleteReminder(afterAdd.reminders.first.id);
|
|
final LocalDataState afterDelete = await container.read(
|
|
localRepositoryProvider.future,
|
|
);
|
|
|
|
expect(scheduler.snapshots, isNotEmpty);
|
|
expect(scheduler.snapshots.first.length, beforeCount);
|
|
expect(
|
|
scheduler.snapshots.any(
|
|
(List<ReminderRule> r) => r.length == beforeCount + 1,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(scheduler.snapshots.last.length, afterDelete.reminders.length);
|
|
});
|
|
}
|
|
|
|
ProviderContainer _createContainer({ReminderScheduler? scheduler}) {
|
|
return ProviderContainer(
|
|
overrides: [
|
|
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
|
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
|
|
if (scheduler != null)
|
|
reminderSchedulerProvider.overrideWithValue(scheduler),
|
|
],
|
|
);
|
|
}
|
|
|
|
class RecordingReminderScheduler implements ReminderScheduler {
|
|
final List<List<ReminderRule>> snapshots = <List<ReminderRule>>[];
|
|
|
|
@override
|
|
Future<void> clearAll() async {}
|
|
|
|
@override
|
|
Future<bool> requestPermissions() async => true;
|
|
|
|
@override
|
|
Future<void> reconcile(List<ReminderRule> reminders) async {
|
|
snapshots.add(List<ReminderRule>.unmodifiable(reminders));
|
|
}
|
|
}
|