import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('MongoDB V1 document codecs', () { final createdAt = DateTime.utc(2026, 6, 23, 8); final updatedAt = DateTime.utc(2026, 6, 23, 8, 5); test('task documents round-trip full and nullable V1 fields', () { final task = scheduledTask( id: 'task-1', createdAt: createdAt, reward: RewardLevel.high, difficulty: DifficultyLevel.hard, ).copyWith(stats: const TaskStatistics().incrementManualPush()); final document = task.toDocument( ownerId: 'owner-1', revision: 4, backlogEnteredAt: createdAt, backlogEnteredAtProvenance: 'recorded', )..['forwardCompatibleExtra'] = 'ignored'; final restored = TaskDocumentMapping.fromDocument(document); expect(document[DocumentFields.schemaVersion], v1SchemaVersion); expect(document[TaskDocumentFields.ownerId], 'owner-1'); expect(document[TaskDocumentFields.revision], 4); expect(document[TaskDocumentFields.type], 'flexible'); expect(document[TaskDocumentFields.status], 'planned'); expect(document[TaskDocumentFields.reward], 'high'); expect(document[TaskDocumentFields.difficulty], 'hard'); expect(document[TaskDocumentFields.scheduledStart], '2026-06-23T09:00:00.000Z'); expect(restored.id, task.id); expect(restored.type, task.type); expect(restored.status, task.status); expect(restored.reward, task.reward); expect(restored.difficulty, task.difficulty); expect(restored.scheduledStart, task.scheduledStart); expect(restored.actualStart, task.actualStart); expect(restored.completedAt, task.completedAt); expect(restored.backlogTags, task.backlogTags); expect(restored.stats.manuallyPushedCount, 1); final nullableTask = Task( id: 'task-nullable', title: 'Nullable task', projectId: 'inbox', type: TaskType.flexible, status: TaskStatus.backlog, createdAt: createdAt, updatedAt: updatedAt, ); final nullableDocument = nullableTask.toDocument(ownerId: 'owner-1'); final nullableRestored = TaskDocumentMapping.fromDocument( nullableDocument, ); expect(nullableDocument[TaskDocumentFields.priority], isNull); expect(nullableDocument[TaskDocumentFields.durationMinutes], isNull); expect(nullableDocument[TaskDocumentFields.scheduledStart], isNull); expect(nullableDocument[TaskDocumentFields.backlogEnteredAt], isNull); expect(nullableRestored.priority, isNull); expect(nullableRestored.durationMinutes, isNull); expect(nullableRestored.scheduledStart, isNull); }); test('task documents preserve explicit schedule clearing', () { final task = scheduledTask(id: 'scheduled', createdAt: createdAt); final document = task.toDocument( ownerId: 'owner-1', clearSchedule: true, ); final restored = TaskDocumentMapping.fromDocument(document); expect(document[TaskDocumentFields.scheduledStart], isNull); expect(document[TaskDocumentFields.scheduledEnd], isNull); expect(restored.scheduledStart, isNull); expect(restored.scheduledEnd, isNull); expect(restored.actualStart, task.actualStart); }); test('task statistics document mapping preserves every counter', () { const stats = TaskStatistics( skippedDuringBurnoutCount: 1, manuallyPushedCount: 2, autoPushedCount: 3, movedToBacklogCount: 4, restoredFromBacklogCount: 5, missedCount: 6, cancelledCount: 7, completedLateCount: 8, completedDuringLockedHoursCount: 9, completedDuringLockedHoursMinutes: 10, completedAfterShieldCount: 11, completedAfterPushCount: 12, totalPushesBeforeCompletion: 13, ); final document = stats.toDocument(); final restored = TaskStatisticsDocumentMapping.fromDocument(document); expect(document.keys.toSet(), TaskStatisticsDocumentFields.all); expect(restored.skippedDuringBurnoutCount, 1); expect(restored.manuallyPushedCount, 2); expect(restored.autoPushedCount, 3); expect(restored.movedToBacklogCount, 4); expect(restored.restoredFromBacklogCount, 5); expect(restored.missedCount, 6); expect(restored.cancelledCount, 7); expect(restored.completedLateCount, 8); expect(restored.completedDuringLockedHoursCount, 9); expect(restored.completedDuringLockedHoursMinutes, 10); expect(restored.completedAfterShieldCount, 11); expect(restored.completedAfterPushCount, 12); expect(restored.totalPushesBeforeCompletion, 13); }); test('project profile documents round-trip configured defaults', () { final project = ProjectProfile( id: 'home', name: 'Home', colorKey: 'home-blue', defaultPriority: PriorityLevel.high, defaultReward: RewardLevel.low, defaultDifficulty: DifficultyLevel.easy, defaultReminderProfile: ReminderProfile.persistent, defaultDurationMinutes: 45, archivedAt: DateTime.utc(2026, 6, 24, 9), ); final document = ProjectDocumentMapping.toDocument( project, ownerId: 'owner-1', createdAt: createdAt, updatedAt: updatedAt, revision: 2, ); final restored = ProjectDocumentMapping.fromDocument(document); expect(document.keys.toSet(), ProjectDocumentFields.all); expect(document[ProjectDocumentFields.defaultPriority], 'high'); expect( document[ProjectDocumentFields.defaultReminderProfile], 'persistent'); expect(restored.id, project.id); expect(restored.defaultDurationMinutes, 45); expect(restored.archivedAt, project.archivedAt); }); test('project statistics documents preserve aggregates and applied ids', () { final statistics = ProjectStatistics( projectId: 'home', completedTaskCount: 8, durationMinuteCounts: const {20: 3, 45: 5}, completionTimeBucketCounts: const { ProjectCompletionTimeBucket.morning: 6, ProjectCompletionTimeBucket.evening: 2, }, totalPushesBeforeCompletion: 11, completedAfterPushCount: 4, rewardCounts: const { RewardLevel.high: 5, RewardLevel.low: 3, }, difficultyCounts: const { DifficultyLevel.easy: 2, DifficultyLevel.hard: 6, }, reminderProfileCounts: const { ReminderProfile.gentle: 3, ReminderProfile.persistent: 5, }, ); final document = statistics.toDocument( ownerId: 'owner-1', createdAt: createdAt, updatedAt: updatedAt, appliedActivityIds: const ['activity-1'], ); final restored = ProjectStatisticsDocumentMapping.fromDocument(document); expect(document.keys.toSet(), ProjectStatisticsDocumentFields.all); expect(document[ProjectStatisticsDocumentFields.id], 'home'); expect(document[ProjectStatisticsDocumentFields.projectId], 'home'); expect(document[ProjectStatisticsDocumentFields.durationMinuteCounts], { '20': 3, '45': 5, }); expect(document[ProjectStatisticsDocumentFields.rewardCounts], { 'high': 5, 'low': 3, }); expect(document[ProjectStatisticsDocumentFields.appliedActivityIds], [ 'activity-1', ]); expect(restored.projectId, statistics.projectId); expect(restored.completedTaskCount, 8); expect(restored.durationMinuteCounts, statistics.durationMinuteCounts); expect(restored.completionTimeBucketCounts, statistics.completionTimeBucketCounts); expect(restored.reminderProfileCounts, statistics.reminderProfileCounts); }); test( 'locked block and override documents preserve civil dates and wall time', () { final recurrence = LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.monday, LockedWeekday.thursday}, ); final recurringBlock = LockedBlock( id: 'work', name: 'Work', startTime: WallTime(hour: 9, minute: 0), endTime: WallTime(hour: 17, minute: 0), recurrence: recurrence, hiddenByDefault: true, createdAt: createdAt, updatedAt: updatedAt, archivedAt: DateTime.utc(2026, 6, 30), ); final replaceOverride = LockedBlockOverride.replace( id: 'replace-work', lockedBlockId: 'work', date: CivilDate(2026, 6, 25), startTime: WallTime(hour: 10, minute: 0), endTime: WallTime(hour: 15, minute: 0), createdAt: createdAt, name: 'Short work', updatedAt: updatedAt, ); final addOverride = LockedBlockOverride.add( id: 'appointment', date: CivilDate(2026, 6, 25), name: 'Appointment', startTime: WallTime(hour: 18, minute: 0), endTime: WallTime(hour: 19, minute: 0), createdAt: createdAt, ); final removeOverride = LockedBlockOverride.remove( id: 'remove-work', lockedBlockId: 'work', date: CivilDate(2026, 6, 26), createdAt: createdAt, ); final blockDocument = LockedBlockDocumentMapping.toDocument( recurringBlock, ownerId: 'owner-1', ); final restoredBlock = LockedBlockDocumentMapping.fromDocument( blockDocument, ); final replaceDocument = LockedBlockOverrideDocumentMapping.toDocument( replaceOverride, ownerId: 'owner-1', ); final addDocument = LockedBlockOverrideDocumentMapping.toDocument( addOverride, ownerId: 'owner-1', ); final removeDocument = LockedBlockOverrideDocumentMapping.toDocument( removeOverride, ownerId: 'owner-1', ); expect(blockDocument[LockedBlockDocumentFields.startTime], '09:00'); expect( (blockDocument[LockedBlockDocumentFields.recurrence] as Map)[LockedBlockRecurrenceDocumentFields.weekdays], ['monday', 'thursday'], ); expect(restoredBlock.recurrence!.weekdays, recurrence.weekdays); expect(restoredBlock.archivedAt, recurringBlock.archivedAt); expect(replaceDocument[LockedBlockOverrideDocumentFields.date], '2026-06-25'); expect( replaceDocument[LockedBlockOverrideDocumentFields.type], 'replace'); expect(addDocument[LockedBlockOverrideDocumentFields.type], 'add'); expect(removeDocument[LockedBlockOverrideDocumentFields.type], 'remove'); expect( LockedBlockOverrideDocumentMapping.fromDocument(replaceDocument).date, CivilDate(2026, 6, 25), ); expect( LockedBlockOverrideDocumentMapping.fromDocument(removeDocument) .startTime, isNull, ); }); test('settings, notices, operations, and activities round-trip', () { final settings = OwnerSettings( ownerId: 'owner-1', timeZoneId: 'America/Los_Angeles', dayStart: WallTime(hour: 7, minute: 30), dayEnd: WallTime(hour: 21, minute: 0), compactModeEnabled: true, backlogStalenessSettings: const BacklogStalenessSettings( greenMaxAge: Duration(days: 3), blueMaxAge: Duration(days: 10), ), ); final activity = TaskActivity( id: 'activity-1', operationId: 'op-1', code: TaskActivityCode.completed, taskId: 'task-1', projectId: 'home', occurredAt: createdAt, metadata: {'completedAt': createdAt}, ); final acknowledgement = NoticeAcknowledgementRecord( noticeId: 'snapshot:0', ownerId: 'owner-1', acknowledgedAt: updatedAt, ); final operation = ApplicationOperationRecord( operationId: 'op-1', ownerId: 'owner-1', operationName: 'completeTask', committedAt: updatedAt, ); final settingsDocument = OwnerSettingsDocumentMapping.toDocument( settings, createdAt: createdAt, updatedAt: updatedAt, ); final activityDocument = TaskActivityDocumentMapping.toDocument( activity, ownerId: 'owner-1', ); final acknowledgementDocument = NoticeAcknowledgementDocumentMapping.toDocument(acknowledgement); final operationDocument = ApplicationOperationDocumentMapping.toDocument( operation, ); expect(settingsDocument[OwnerSettingsDocumentFields.dayStart], '07:30'); expect( (settingsDocument[OwnerSettingsDocumentFields.backlogStaleness] as Map< String, Object?>)[BacklogStalenessDocumentFields.blueMaxAgeDays], 10, ); expect(activityDocument[TaskActivityDocumentFields.code], 'completed'); expect( (activityDocument[TaskActivityDocumentFields.metadata] as Map)['completedAt'], '2026-06-23T08:00:00.000Z', ); expect(acknowledgementDocument[NoticeAcknowledgementDocumentFields.id], 'snapshot:0'); expect(operationDocument[ApplicationOperationDocumentFields.operationId], 'op-1'); expect(OwnerSettingsDocumentMapping.fromDocument(settingsDocument).dayEnd, settings.dayEnd); expect(TaskActivityDocumentMapping.fromDocument(activityDocument).code, TaskActivityCode.completed); expect( NoticeAcknowledgementDocumentMapping.fromDocument( acknowledgementDocument, ).noticeId, 'snapshot:0', ); expect( ApplicationOperationDocumentMapping.fromDocument(operationDocument) .operationName, 'completeTask', ); }); test('scheduling snapshot structures round-trip nested data', () { final task = scheduledTask(id: 'task-1', createdAt: createdAt); final snapshot = SchedulingStateSnapshot( id: 'rollover:owner-1:2026-06-25', capturedAt: updatedAt, window: SchedulingWindow( start: DateTime.utc(2026, 6, 24, 9), end: DateTime.utc(2026, 6, 24, 17), ), tasks: [task], lockedIntervals: [ TimeInterval( start: DateTime.utc(2026, 6, 24, 12), end: DateTime.utc(2026, 6, 24, 13), label: 'Hidden locked', ), ], notices: [ SchedulingNotice( '1 unfinished flexible task was moved to today.', type: SchedulingNoticeType.moved, movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, parameters: {'count': 1}, ), ], changes: [ SchedulingChange( taskId: task.id, previousStart: task.scheduledStart, previousEnd: task.scheduledEnd, nextStart: DateTime.utc(2026, 6, 24, 9), nextEnd: DateTime.utc(2026, 6, 24, 9, 30), ), ], overlaps: [ SchedulingOverlap( taskId: task.id, taskInterval: TimeInterval( start: DateTime.utc(2026, 6, 23, 9), end: DateTime.utc(2026, 6, 23, 9, 30), ), blockedInterval: TimeInterval( start: DateTime.utc(2026, 6, 23, 9, 15), end: DateTime.utc(2026, 6, 23, 9, 45), ), ), ], operationName: 'appOpenRecovery', ); final document = SchedulingSnapshotDocumentMapping.toDocument( snapshot, ownerId: 'owner-1', sourceDate: CivilDate(2026, 6, 23), targetDate: CivilDate(2026, 6, 24), retentionExpiresAt: DateTime.utc(2026, 7, 24), ); final restored = SchedulingSnapshotDocumentMapping.fromDocument(document); expect(document.keys.toSet(), SchedulingSnapshotDocumentFields.all); expect( document[SchedulingSnapshotDocumentFields.sourceDate], '2026-06-23'); expect(restored.id, snapshot.id); expect(restored.window.start, snapshot.window.start); expect(restored.tasks.single.id, task.id); expect(restored.lockedIntervals.single.label, 'Hidden locked'); expect(restored.notices.single.movementCode, SchedulingMovementCode.unfinishedFlexibleTasksRolledOver); expect(restored.notices.single.parameters['count'], 1); expect(restored.changes.single.taskId, task.id); expect(restored.overlaps.single.taskId, task.id); }); test('stable enum code tables do not depend on Dart enum names', () { expect( PersistenceEnumMapping.encodeReward(RewardLevel.notSet), 'not_set'); expect( PersistenceEnumMapping.encodeTaskStatus( TaskStatus.noLongerRelevant, ), 'no_longer_relevant'); expect(PersistenceEnumMapping.encodeTaskType(TaskType.freeSlot), 'free_slot'); expect( PersistenceEnumMapping.decodeTaskStatus('no_longer_relevant'), TaskStatus.noLongerRelevant, ); expect( PersistenceEnumMapping.decodeReward('not_set'), RewardLevel.notSet, ); }); test('malformed documents produce typed mapping failures', () { final valid = scheduledTask(id: 'task-1', createdAt: createdAt) .toDocument(ownerId: 'owner-1'); expect( () => TaskDocumentMapping.fromDocument( {...valid}..remove(TaskDocumentFields.title), ), throwsMapping( DocumentMappingFailureCode.missingField, TaskDocumentFields.title), ); expect( () => TaskDocumentMapping.fromDocument({ ...valid, TaskDocumentFields.reward: 'notSet', }), throwsMapping(DocumentMappingFailureCode.unknownCode, 'RewardLevel'), ); expect( () => TaskDocumentMapping.fromDocument({ ...valid, TaskDocumentFields.durationMinutes: '30', }), throwsMapping(DocumentMappingFailureCode.wrongType, TaskDocumentFields.durationMinutes), ); expect( () => TaskDocumentMapping.fromDocument({ ...valid, DocumentFields.revision: 0, }), throwsMapping(DocumentMappingFailureCode.invalidRevision, DocumentFields.revision), ); expect( () => TaskDocumentMapping.fromDocument({ ...valid, DocumentFields.schemaVersion: 99, }), throwsMapping(DocumentMappingFailureCode.invalidSchemaVersion, DocumentFields.schemaVersion), ); expect( () => SchedulingWindowDocumentMapping.fromDocument({ SchedulingWindowDocumentFields.start: '2026-06-23T10:00:00.000Z', SchedulingWindowDocumentFields.end: '2026-06-23T09:00:00.000Z', }), throwsMapping(DocumentMappingFailureCode.invalidValue), ); }); }); } Matcher throwsMapping( DocumentMappingFailureCode code, [ String? fieldName, ]) { var matcher = isA().having( (error) => error.code, 'code', code, ); if (fieldName != null) { matcher = matcher.having( (error) => error.fieldName, 'fieldName', fieldName, ); } return throwsA(matcher); } Task scheduledTask({ required String id, required DateTime createdAt, RewardLevel reward = RewardLevel.notSet, DifficultyLevel difficulty = DifficultyLevel.medium, }) { return Task( id: id, title: 'Scheduled task', projectId: 'project-1', type: TaskType.flexible, status: TaskStatus.planned, priority: PriorityLevel.medium, reward: reward, difficulty: difficulty, durationMinutes: 30, scheduledStart: DateTime.utc(2026, 6, 23, 9), scheduledEnd: DateTime.utc(2026, 6, 23, 9, 30), actualStart: DateTime.utc(2026, 6, 23, 9, 5), actualEnd: DateTime.utc(2026, 6, 23, 9, 25), completedAt: DateTime.utc(2026, 6, 23, 9, 30), parentTaskId: 'parent-1', backlogTags: const {BacklogTag.wishlist}, reminderOverride: ReminderProfile.persistent, createdAt: createdAt, updatedAt: createdAt.add(const Duration(minutes: 5)), ); }