forked from eva/focus-flow
363 lines
11 KiB
Dart
363 lines
11 KiB
Dart
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('Domain invariants', () {
|
|
final now = DateTime(2026, 6, 24, 9);
|
|
|
|
test('task rejects blank identifiers titles and project ids with codes',
|
|
() {
|
|
expectValidationCode(
|
|
() => _task(now: now, id: ' '),
|
|
DomainValidationCode.blankStableId,
|
|
);
|
|
expectValidationCode(
|
|
() => _task(now: now, title: ' '),
|
|
DomainValidationCode.blankTitle,
|
|
);
|
|
expectValidationCode(
|
|
() => _task(now: now, projectId: ' '),
|
|
DomainValidationCode.blankProjectId,
|
|
);
|
|
});
|
|
|
|
test('task rejects invalid duration and schedule intervals', () {
|
|
expectValidationCode(
|
|
() => _task(now: now, durationMinutes: 0),
|
|
DomainValidationCode.nonPositiveDuration,
|
|
);
|
|
expectValidationCode(
|
|
() => _task(
|
|
now: now,
|
|
scheduledStart: DateTime(2026, 6, 24, 9),
|
|
),
|
|
DomainValidationCode.partialScheduledInterval,
|
|
);
|
|
expectValidationCode(
|
|
() => _task(
|
|
now: now,
|
|
scheduledStart: DateTime(2026, 6, 24, 10),
|
|
scheduledEnd: DateTime(2026, 6, 24, 9),
|
|
),
|
|
DomainValidationCode.invalidScheduledInterval,
|
|
);
|
|
});
|
|
|
|
test('task rejects invalid actual intervals and self parenting', () {
|
|
expectValidationCode(
|
|
() => _task(
|
|
now: now,
|
|
actualStart: DateTime(2026, 6, 24, 9),
|
|
),
|
|
DomainValidationCode.partialActualInterval,
|
|
);
|
|
expectValidationCode(
|
|
() => _task(
|
|
now: now,
|
|
actualStart: DateTime(2026, 6, 24, 10),
|
|
actualEnd: DateTime(2026, 6, 24, 9),
|
|
),
|
|
DomainValidationCode.invalidActualInterval,
|
|
);
|
|
expectValidationCode(
|
|
() => _task(now: now, id: 'task-1', parentTaskId: 'task-1'),
|
|
DomainValidationCode.selfParent,
|
|
);
|
|
});
|
|
|
|
test('copyWith has explicit clear paths for nullable task fields', () {
|
|
final task = _task(
|
|
now: now,
|
|
priority: PriorityLevel.high,
|
|
durationMinutes: 25,
|
|
scheduledStart: DateTime(2026, 6, 24, 9),
|
|
scheduledEnd: DateTime(2026, 6, 24, 9, 25),
|
|
actualStart: DateTime(2026, 6, 24, 9, 5),
|
|
actualEnd: DateTime(2026, 6, 24, 9, 20),
|
|
completedAt: DateTime(2026, 6, 24, 9, 25),
|
|
parentTaskId: 'parent-1',
|
|
reminderOverride: ReminderProfile.strict,
|
|
);
|
|
|
|
final preserved = task.copyWith(title: 'Renamed');
|
|
final cleared = task.copyWith(
|
|
clearPriority: true,
|
|
clearDuration: true,
|
|
clearSchedule: true,
|
|
clearActualInterval: true,
|
|
clearCompletion: true,
|
|
clearParentTask: true,
|
|
clearReminderOverride: true,
|
|
);
|
|
|
|
expect(preserved.priority, PriorityLevel.high);
|
|
expect(preserved.durationMinutes, 25);
|
|
expect(preserved.scheduledStart, DateTime(2026, 6, 24, 9));
|
|
expect(preserved.actualStart, DateTime(2026, 6, 24, 9, 5));
|
|
expect(preserved.completedAt, DateTime(2026, 6, 24, 9, 25));
|
|
expect(preserved.parentTaskId, 'parent-1');
|
|
expect(preserved.reminderOverride, ReminderProfile.strict);
|
|
|
|
expect(cleared.priority, isNull);
|
|
expect(cleared.durationMinutes, isNull);
|
|
expect(cleared.scheduledStart, isNull);
|
|
expect(cleared.scheduledEnd, isNull);
|
|
expect(cleared.actualStart, isNull);
|
|
expect(cleared.actualEnd, isNull);
|
|
expect(cleared.completedAt, isNull);
|
|
expect(cleared.parentTaskId, isNull);
|
|
expect(cleared.reminderOverride, isNull);
|
|
});
|
|
|
|
test('mutable task collections do not leak into domain objects', () {
|
|
final tags = {BacklogTag.wishlist};
|
|
final task = _task(now: now, backlogTags: tags);
|
|
|
|
tags.clear();
|
|
|
|
expect(task.backlogTags, {BacklogTag.wishlist});
|
|
expect(() => task.backlogTags.clear(), throwsUnsupportedError);
|
|
});
|
|
|
|
test('quick capture reports invalid durations without throwing', () {
|
|
const service = QuickCaptureService();
|
|
final result = service.capture(
|
|
QuickCaptureRequest(
|
|
id: 'capture-1',
|
|
title: 'Buy stamps',
|
|
createdAt: now,
|
|
durationMinutes: -5,
|
|
),
|
|
);
|
|
|
|
expect(result.status, QuickCaptureStatus.validationError);
|
|
expect(result.task.durationMinutes, isNull);
|
|
expect(result.messages.single, contains('positive'));
|
|
});
|
|
|
|
test('project profile validates identifiers and default duration', () {
|
|
expectValidationCode(
|
|
() => ProjectProfile(id: ' ', name: 'Home', colorKey: 'blue'),
|
|
DomainValidationCode.blankStableId,
|
|
);
|
|
expectValidationCode(
|
|
() => ProjectProfile(id: 'home', name: ' ', colorKey: 'blue'),
|
|
DomainValidationCode.blankName,
|
|
);
|
|
expectValidationCode(
|
|
() => ProjectProfile(id: 'home', name: 'Home', colorKey: ' '),
|
|
DomainValidationCode.blankColorKey,
|
|
);
|
|
expectValidationCode(
|
|
() => ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'blue',
|
|
defaultDurationMinutes: -1,
|
|
),
|
|
DomainValidationCode.nonPositiveDuration,
|
|
);
|
|
|
|
final project = ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'blue',
|
|
defaultDurationMinutes: 20,
|
|
);
|
|
expect(
|
|
project.copyWith(clearDefaultDuration: true).defaultDurationMinutes,
|
|
isNull);
|
|
});
|
|
|
|
test('time interval and scheduling window reject non-positive ranges', () {
|
|
expectValidationCode(
|
|
() => TimeInterval(
|
|
start: DateTime(2026, 6, 24, 9),
|
|
end: DateTime(2026, 6, 24, 9),
|
|
),
|
|
DomainValidationCode.invalidTimeInterval,
|
|
);
|
|
expectValidationCode(
|
|
() => SchedulingWindow(
|
|
start: DateTime(2026, 6, 24, 10),
|
|
end: DateTime(2026, 6, 24, 9),
|
|
),
|
|
DomainValidationCode.invalidSchedulingWindow,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Locked-time invariants', () {
|
|
final now = DateTime(2026, 6, 24, 9);
|
|
|
|
test('clock time and recurrence validate real values', () {
|
|
expectValidationCode(
|
|
() => ClockTime(hour: 24, minute: 0),
|
|
DomainValidationCode.invalidClockTime,
|
|
);
|
|
expectValidationCode(
|
|
() => LockedBlockRecurrence.weekly(weekdays: const {}),
|
|
DomainValidationCode.emptyRecurrence,
|
|
);
|
|
});
|
|
|
|
test('locked blocks require a source date or recurrence and positive time',
|
|
() {
|
|
expectValidationCode(
|
|
() => LockedBlock(
|
|
id: 'locked-1',
|
|
name: 'Work',
|
|
startTime: ClockTime(hour: 9, minute: 0),
|
|
endTime: ClockTime(hour: 10, minute: 0),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
),
|
|
DomainValidationCode.invalidLockedBlock,
|
|
);
|
|
expectValidationCode(
|
|
() => LockedBlock(
|
|
id: 'locked-1',
|
|
name: 'Work',
|
|
startTime: ClockTime(hour: 10, minute: 0),
|
|
endTime: ClockTime(hour: 9, minute: 0),
|
|
date: CivilDate.fromDateTime(now),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
),
|
|
DomainValidationCode.invalidLockedBlock,
|
|
);
|
|
});
|
|
|
|
test('locked block overrides enforce valid shapes', () {
|
|
expectValidationCode(
|
|
() => LockedBlockOverride.add(
|
|
id: 'override-1',
|
|
date: CivilDate.fromDateTime(now),
|
|
name: 'Extra work',
|
|
startTime: ClockTime(hour: 12, minute: 0),
|
|
endTime: ClockTime(hour: 11, minute: 0),
|
|
createdAt: now,
|
|
),
|
|
DomainValidationCode.invalidLockedOverride,
|
|
);
|
|
expectValidationCode(
|
|
() => LockedBlockOverride(
|
|
id: 'override-2',
|
|
date: CivilDate.fromDateTime(now),
|
|
type: LockedBlockOverrideType.remove,
|
|
name: 'Invalid remove',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
),
|
|
DomainValidationCode.invalidLockedOverride,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Collection immutability', () {
|
|
final now = DateTime(2026, 6, 24, 9);
|
|
|
|
test('scheduling input and result copy mutable lists', () {
|
|
final task = _task(now: now);
|
|
final sourceTasks = [task];
|
|
final input = SchedulingInput(
|
|
tasks: sourceTasks,
|
|
window: SchedulingWindow(
|
|
start: DateTime(2026, 6, 24, 9),
|
|
end: DateTime(2026, 6, 24, 10),
|
|
),
|
|
);
|
|
final result = SchedulingResult(tasks: sourceTasks);
|
|
|
|
sourceTasks.clear();
|
|
|
|
expect(input.tasks, [task]);
|
|
expect(result.tasks, [task]);
|
|
expect(() => input.tasks.add(task), throwsUnsupportedError);
|
|
expect(() => result.tasks.add(task), throwsUnsupportedError);
|
|
});
|
|
|
|
test('view and action result collections are immutable snapshots', () {
|
|
final task = _task(now: now);
|
|
final tasks = [task];
|
|
final backlogView = BacklogView(tasks: tasks, now: now);
|
|
final childView = ChildTaskView(tasks: tasks);
|
|
final quickResult = QuickCaptureResult(
|
|
task: task,
|
|
status: QuickCaptureStatus.validationError,
|
|
messages: ['Need duration'],
|
|
);
|
|
final completionResult = ChildTaskCompletionResult(
|
|
tasks: tasks,
|
|
changedTaskIds: ['task-1'],
|
|
);
|
|
|
|
tasks.clear();
|
|
|
|
expect(backlogView.tasks, [task]);
|
|
expect(childView.tasks, [task]);
|
|
expect(quickResult.messages, ['Need duration']);
|
|
expect(completionResult.tasks, [task]);
|
|
expect(() => backlogView.tasks.add(task), throwsUnsupportedError);
|
|
expect(() => childView.tasks.add(task), throwsUnsupportedError);
|
|
expect(() => quickResult.messages.add('Other'), throwsUnsupportedError);
|
|
expect(() => completionResult.changedTaskIds.add('task-2'),
|
|
throwsUnsupportedError);
|
|
});
|
|
});
|
|
}
|
|
|
|
void expectValidationCode(
|
|
Object? Function() callback,
|
|
DomainValidationCode code,
|
|
) {
|
|
expect(
|
|
callback,
|
|
throwsA(
|
|
isA<DomainValidationException>().having(
|
|
(error) => error.code,
|
|
'code',
|
|
code,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Task _task({
|
|
required DateTime now,
|
|
String id = 'task-1',
|
|
String title = 'Task',
|
|
String projectId = 'project-1',
|
|
TaskType type = TaskType.flexible,
|
|
TaskStatus status = TaskStatus.planned,
|
|
PriorityLevel? priority,
|
|
int? durationMinutes,
|
|
DateTime? scheduledStart,
|
|
DateTime? scheduledEnd,
|
|
DateTime? actualStart,
|
|
DateTime? actualEnd,
|
|
DateTime? completedAt,
|
|
String? parentTaskId,
|
|
Set<BacklogTag> backlogTags = const {},
|
|
ReminderProfile? reminderOverride,
|
|
}) {
|
|
return Task(
|
|
id: id,
|
|
title: title,
|
|
projectId: projectId,
|
|
type: type,
|
|
status: status,
|
|
priority: priority,
|
|
durationMinutes: durationMinutes,
|
|
scheduledStart: scheduledStart,
|
|
scheduledEnd: scheduledEnd,
|
|
actualStart: actualStart,
|
|
actualEnd: actualEnd,
|
|
completedAt: completedAt,
|
|
parentTaskId: parentTaskId,
|
|
backlogTags: backlogTags,
|
|
reminderOverride: reminderOverride,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
}
|