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().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 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, ); }