focus-flow/test/persistence_edge_cases_test.dart

316 lines
9.9 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Persistence edge-case regressions', () {
final createdAt = DateTime.utc(2026, 6, 22, 8);
test('task ids remain stable across copy and update operations', () {
final original = scheduledTask(
id: 'task-1',
createdAt: createdAt,
);
final updated = original.copyWith(
title: 'Updated title',
status: TaskStatus.active,
updatedAt: createdAt.add(const Duration(minutes: 5)),
);
expect(updated.id, original.id);
expect(updated.title, 'Updated title');
expect(updated.status, TaskStatus.active);
});
test('project ids remain stable across copy and update operations', () {
final original = ProjectProfile(
id: 'project-1',
name: 'Project',
colorKey: 'project-blue',
);
final updated = original.copyWith(
name: 'Renamed project',
colorKey: 'project-green',
);
expect(updated.id, original.id);
expect(updated.name, 'Renamed project');
expect(updated.colorKey, 'project-green');
});
test('locked block ids remain stable across copy and update operations',
() {
final original = LockedBlock(
id: 'locked-1',
name: 'Work',
startTime: ClockTime(hour: 9, minute: 0),
endTime: ClockTime(hour: 17, minute: 0),
recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.monday},
),
createdAt: createdAt,
updatedAt: createdAt,
);
final updated = original.copyWith(
name: 'Adjusted work',
hiddenByDefault: false,
updatedAt: createdAt.add(const Duration(minutes: 10)),
);
expect(updated.id, original.id);
expect(updated.name, 'Adjusted work');
expect(updated.hiddenByDefault, isFalse);
});
test('DateTime persistence uses UTC ISO-8601 strings and UTC comparison',
() {
final pacificTime = DateTime.parse('2026-06-22T09:00:00-07:00');
final sameInstantUtc = DateTime.utc(2026, 6, 22, 16);
final stored = PersistenceDateTimeConvention.toStoredString(pacificTime);
final restored = PersistenceDateTimeConvention.fromStoredString(stored);
expect(PersistenceDateTimeConvention.description, contains('UTC'));
expect(stored, '2026-06-22T16:00:00.000Z');
expect(restored, sameInstantUtc);
expect(
PersistenceDateTimeConvention.compare(pacificTime, sameInstantUtc),
0,
);
});
test('civil dates and wall times persist without timezone conversion', () {
final date = CivilDate(2026, 3, 8);
final wallTime = WallTime(hour: 2, minute: 30);
final storedDate = PersistenceCivilDateConvention.toStoredString(date);
final storedWallTime =
PersistenceWallTimeConvention.toStoredString(wallTime);
expect(
PersistenceCivilDateConvention.description, contains('YYYY-MM-DD'));
expect(PersistenceWallTimeConvention.description, contains('HH:MM'));
expect(storedDate, '2026-03-08');
expect(storedWallTime, '02:30');
expect(
PersistenceCivilDateConvention.fromStoredString(storedDate),
date,
);
expect(
PersistenceWallTimeConvention.fromStoredString(storedWallTime),
wallTime,
);
});
test('nullable task fields are preserved unless explicitly cleared', () {
final original = scheduledTask(
id: 'nullable-task',
createdAt: createdAt,
).copyWith(
priority: PriorityLevel.high,
parentTaskId: 'parent',
);
final preserved = original.copyWith(title: 'Updated');
final clearedSchedule = original.copyWith(clearSchedule: true);
expect(preserved.priority, PriorityLevel.high);
expect(preserved.durationMinutes, original.durationMinutes);
expect(preserved.scheduledStart, original.scheduledStart);
expect(preserved.scheduledEnd, original.scheduledEnd);
expect(preserved.parentTaskId, 'parent');
expect(clearedSchedule.scheduledStart, isNull);
expect(clearedSchedule.scheduledEnd, isNull);
expect(clearedSchedule.id, original.id);
});
test('enum persistence names stay stable and distinguish not set values',
() {
expect(TaskType.flexible.persistenceName, 'flexible');
expect(TaskStatus.backlog.persistenceName, 'backlog');
expect(PriorityLevel.veryHigh.persistenceName, 'veryHigh');
expect(RewardLevel.notSet.persistenceName, 'notSet');
expect(RewardLevel.veryLow.persistenceName, 'veryLow');
expect(DifficultyLevel.veryHard.persistenceName, 'veryHard');
expect(ReminderProfile.gentle.persistenceName, 'gentle');
expect(BacklogTag.wishlist.persistenceName, 'wishlist');
expect(LockedWeekday.monday.persistenceName, 'monday');
expect(LockedBlockOverrideType.replace.persistenceName, 'replace');
expect(
RewardLevel.notSet.persistenceName,
isNot(RewardLevel.veryLow.persistenceName),
);
});
test('backlog age behavior uses task creation timestamp', () {
final oldButRecentlyUpdated = Task.quickCapture(
id: 'old',
title: 'Old backlog idea',
createdAt: DateTime.utc(2026, 5, 1),
updatedAt: DateTime.utc(2026, 6, 21),
);
final view = BacklogView(
tasks: [oldButRecentlyUpdated],
now: DateTime.utc(2026, 6, 22),
);
expect(view.filter(BacklogFilter.stale), [oldButRecentlyUpdated]);
expect(
view.stalenessMarkerFor(oldButRecentlyUpdated),
BacklogStalenessMarker.purple,
);
});
test('in-memory repositories round-trip scheduling fields', () async {
final task = scheduledTask(
id: 'scheduled',
createdAt: createdAt,
);
final repository = InMemoryTaskRepository();
await repository.save(task);
final saved = await repository.findById(task.id);
expect(saved, isNotNull);
expect(saved!.id, task.id);
expect(saved.scheduledStart, task.scheduledStart);
expect(saved.scheduledEnd, task.scheduledEnd);
expect(saved.durationMinutes, task.durationMinutes);
expect(saved.projectId, task.projectId);
expect(saved.reward, task.reward);
expect(saved.difficulty, task.difficulty);
});
test('repository read operations do not expose mutable collections',
() async {
final original = scheduledTask(
id: 'immutable',
createdAt: createdAt,
);
final repository = InMemoryTaskRepository([original]);
final savedList = await repository.findAll();
expect(() => savedList.add(original), throwsUnsupportedError);
expect((await repository.findAll()).map((task) => task.id), [
'immutable',
]);
expect(original.scheduledStart, DateTime.utc(2026, 6, 22, 9));
});
test('document field sets preserve all task and statistics fields', () {
expect(TaskDocumentFields.all, {
'_id',
'title',
'projectId',
'type',
'status',
'priority',
'reward',
'difficulty',
'durationMinutes',
'scheduledStart',
'scheduledEnd',
'actualStart',
'actualEnd',
'completedAt',
'parentTaskId',
'backlogTags',
'reminderOverride',
'createdAt',
'updatedAt',
'stats',
});
expect(TaskActivityDocumentFields.all, {
'_id',
'operationId',
'code',
'taskId',
'projectId',
'occurredAt',
'metadata',
});
expect(TaskStatisticsDocumentFields.all, {
'skippedDuringBurnoutCount',
'manuallyPushedCount',
'autoPushedCount',
'movedToBacklogCount',
'restoredFromBacklogCount',
'missedCount',
'cancelledCount',
'completedLateCount',
'completedDuringLockedHoursCount',
'completedDuringLockedHoursMinutes',
});
});
test('document field sets cover project locked and snapshot documents', () {
expect(ProjectDocumentFields.all, containsAll(['_id', 'colorKey']));
expect(
LockedBlockDocumentFields.all,
containsAll(['_id', 'startTime', 'recurrence', 'hiddenByDefault']),
);
expect(
LockedBlockOverrideDocumentFields.all,
containsAll(['_id', 'lockedBlockId', 'type']),
);
expect(
SchedulingSnapshotDocumentFields.all,
containsAll([
'_id',
'capturedAt',
'window',
'tasks',
'lockedIntervals',
'requiredVisibleIntervals',
'notices',
'changes',
'overlaps',
'operationName',
]),
);
});
test('document field names are stable and migration-friendly', () {
for (final fieldSet in PersistenceDocumentFieldSets.all) {
expect(fieldSet, isNotEmpty);
for (final fieldName in fieldSet) {
expect(fieldName, isNotEmpty);
expect(fieldName.trim(), fieldName);
expect(fieldName.contains('.'), isFalse, reason: fieldName);
expect(fieldName.startsWith(r'$'), isFalse, reason: fieldName);
}
}
expect(DocumentFields.id, '_id');
expect(TaskDocumentFields.projectId, 'projectId');
expect(TaskDocumentFields.scheduledStart, 'scheduledStart');
expect(TaskDocumentFields.scheduledEnd, 'scheduledEnd');
});
});
}
Task scheduledTask({
required String id,
required DateTime createdAt,
}) {
return Task(
id: id,
title: 'Scheduled task',
projectId: 'project-1',
type: TaskType.flexible,
status: TaskStatus.planned,
priority: PriorityLevel.medium,
reward: RewardLevel.high,
difficulty: DifficultyLevel.medium,
durationMinutes: 30,
scheduledStart: DateTime.utc(2026, 6, 22, 9),
scheduledEnd: DateTime.utc(2026, 6, 22, 9, 30),
createdAt: createdAt,
updatedAt: createdAt,
);
}