283 lines
8.9 KiB
Dart
283 lines
8.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('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',
|
|
'parentTaskId',
|
|
'backlogTags',
|
|
'reminderOverride',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'stats',
|
|
});
|
|
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,
|
|
);
|
|
}
|