focus-flow/test/domain_invariants_test.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,
);
}