focus-flow/test/document_mapping_test.dart

569 lines
20 KiB
Dart

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<String,
Object?>)[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<String, Object?>)['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<DocumentMappingException>().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)),
);
}