import 'dart:math'; import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('Structured scheduling outcomes', () { final now = DateTime(2026, 6, 19, 8); const engine = SchedulingEngine(); test('not-found invalid-state no-slot overflow and no-op paths use codes', () { final window = dayWindow(); final backlog = backlogTask( id: 'backlog', createdAt: now, durationMinutes: 60, ); final planned = flexibleTask( id: 'planned', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final notFound = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput(tasks: [backlog], window: window), taskId: 'missing', updatedAt: now, ); final invalid = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput(tasks: [planned], window: window), taskId: 'planned', updatedAt: now, ); final noSlot = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlog], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 9, 30), ), ), taskId: 'backlog', updatedAt: now, ); final overflow = engine.rollOverUnfinishedFlexibleTasks( input: SchedulingInput( tasks: [ flexibleTask( id: 'source', createdAt: now, start: DateTime(2026, 6, 18, 9), durationMinutes: 60, ), ], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 9, 30), ), ), sourceWindow: SchedulingWindow( start: DateTime(2026, 6, 18), end: DateTime(2026, 6, 19), ), updatedAt: now, ); final noOp = engine.rollOverUnfinishedFlexibleTasks( input: SchedulingInput(tasks: const [], window: window), updatedAt: now, ); expect(notFound.outcomeCode, SchedulingOutcomeCode.notFound); expect( notFound.notices.single.issueCode, SchedulingIssueCode.taskNotFound); expect(invalid.outcomeCode, SchedulingOutcomeCode.invalidState); expect(invalid.notices.single.issueCode, SchedulingIssueCode.invalidTaskState); expect(noSlot.outcomeCode, SchedulingOutcomeCode.noSlot); expect( noSlot.notices.single.issueCode, SchedulingIssueCode.noAvailableSlot); expect(overflow.outcomeCode, SchedulingOutcomeCode.overflow); expect( overflow.notices.single.issueCode, SchedulingIssueCode.unfinishedTasksCouldNotFit, ); expect(noOp.outcomeCode, SchedulingOutcomeCode.noOp); expect( noOp.notices.single.issueCode, SchedulingIssueCode.noUnfinishedFlexibleTasks, ); }); test('conflict paths use conflict codes', () { final flexible = flexibleTask( id: 'flexible', createdAt: now, start: DateTime(2026, 6, 19, 9), durationMinutes: 30, ); final analysis = engine.analyzeSchedule( SchedulingInput( tasks: [flexible], window: dayWindow(), lockedIntervals: [ TimeInterval( start: DateTime(2026, 6, 19, 9, 15), end: DateTime(2026, 6, 19, 9, 45), ), ], ), ); expect(analysis.operationCode, SchedulingOperationCode.analyzeSchedule); expect(analysis.outcomeCode, SchedulingOutcomeCode.conflict); expect( analysis.notices.single.conflictCode, SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, ); }); }); group('Scheduling invariants', () { final now = DateTime(2026, 6, 19, 8); const engine = SchedulingEngine(); for (final seed in [1301, 1302, 1303, 1304, 1305, 1306]) { test('fixed-seed insertion invariants seed $seed', () { final scenario = randomInsertionScenario(seed: seed, createdAt: now); try { final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: scenario.input, taskId: scenario.backlogTaskId, updatedAt: now, ); assertTaskIdsUnique(result.tasks); assertIntervalsValid(result.tasks); if (result.outcomeCode == SchedulingOutcomeCode.success) { assertNoMovableTaskOverlapsBlockers( resultTasks: result.tasks, originalInput: scenario.input, ); assertImmovablePlacementsUnchanged( before: scenario.input, afterTasks: result.tasks, ); assertFlexibleRelativeOrderPreserved( beforeTasks: scenario.input.tasks, afterTasks: result.tasks, ); } else { expect(result.outcomeCode, SchedulingOutcomeCode.noSlot); expect(result.changes, isEmpty); assertTaskSchedulesUnchanged( beforeTasks: scenario.input.tasks, afterTasks: result.tasks, ); } } catch (error, stackTrace) { fail( 'seed=$seed scenario=${scenario.describe()}\n$error\n$stackTrace', ); } }); } test('idempotent surprise replay does not add changes', () { const service = SurpriseTaskLogService(); final first = service.log( request: SurpriseTaskLogRequest( id: 'surprise', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9), timeUsedMinutes: 20, ), input: SchedulingInput(tasks: const [], window: dayWindow()), updatedAt: now, ); final replay = service.log( request: SurpriseTaskLogRequest( id: 'surprise', title: 'Unplanned call', createdAt: now, startedAt: DateTime(2026, 6, 19, 9), timeUsedMinutes: 20, ), input: SchedulingInput( tasks: first.schedulingResult.tasks, window: dayWindow(), ), updatedAt: now, ); expect(replay.schedulingResult.outcomeCode, SchedulingOutcomeCode.noOp); expect(replay.schedulingResult.changes, isEmpty); expect( replay.schedulingResult.notices.single.issueCode, SchedulingIssueCode.duplicateSurpriseLog, ); }); test('large single-day insertion stays inside practical performance guard', () { final scenario = largeInsertionScenario(createdAt: now); final stopwatch = Stopwatch()..start(); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: scenario.input, taskId: scenario.backlogTaskId, updatedAt: now, ); stopwatch.stop(); expect(result.outcomeCode, SchedulingOutcomeCode.success); assertTaskIdsUnique(result.tasks); assertNoMovableTaskOverlapsBlockers( resultTasks: result.tasks, originalInput: scenario.input, ); // This is a regression guard for a large but realistic single-day queue, // not a microbenchmark promise. expect( stopwatch.elapsedMilliseconds, lessThan(2000), reason: 'elapsed=${stopwatch.elapsedMilliseconds}ms', ); }); }); } SchedulingWindow dayWindow() { return SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 17), ); } InsertionScenario randomInsertionScenario({ required int seed, required DateTime createdAt, }) { final random = Random(seed); final window = dayWindow(); final tasks = []; final lockedIntervals = []; final backlog = backlogTask( id: 'backlog-$seed', createdAt: createdAt, durationMinutes: 10 + random.nextInt(16), ); tasks.add(backlog); for (var index = 0; index < 3; index += 1) { final startMinute = random.nextInt(420); final duration = 15 + random.nextInt(30); final start = window.start.add(Duration(minutes: startMinute)); final end = start.add(Duration(minutes: duration)); if (end.isBefore(window.end) || end.isAtSameMomentAs(window.end)) { lockedIntervals.add(TimeInterval(start: start, end: end)); } } var cursor = window.start.add(Duration(minutes: random.nextInt(20))); for (var index = 0; index < 8; index += 1) { cursor = cursor.add(Duration(minutes: random.nextInt(20))); final duration = 10 + random.nextInt(20); final end = cursor.add(Duration(minutes: duration)); if (end.isAfter(window.end)) { break; } tasks.add( flexibleTask( id: 'flex-$seed-$index', createdAt: createdAt, start: cursor, durationMinutes: duration, ), ); cursor = end; } tasks.add( fixedTask( id: 'required-$seed', type: random.nextBool() ? TaskType.critical : TaskType.inflexible, createdAt: createdAt, start: DateTime(2026, 6, 19, 15), durationMinutes: 30, ), ); return InsertionScenario( seed: seed, backlogTaskId: backlog.id, input: SchedulingInput( tasks: tasks, window: window, lockedIntervals: lockedIntervals, ), ); } InsertionScenario largeInsertionScenario({required DateTime createdAt}) { final window = SchedulingWindow( start: DateTime(2026, 6, 19, 6), end: DateTime(2026, 6, 20, 6), ); final backlog = backlogTask( id: 'large-backlog', createdAt: createdAt, durationMinutes: 1, ); final tasks = [backlog]; var cursor = window.start.add(const Duration(minutes: 30)); for (var index = 0; index < 350; index += 1) { tasks.add( flexibleTask( id: 'large-flex-$index', createdAt: createdAt, start: cursor, durationMinutes: 2, ), ); cursor = cursor.add(const Duration(minutes: 3)); } final lockedIntervals = [ for (var hour = 8; hour < 20; hour += 3) TimeInterval( start: DateTime(2026, 6, 19, hour), end: DateTime(2026, 6, 19, hour, 10), ), ]; return InsertionScenario( seed: 0, backlogTaskId: backlog.id, input: SchedulingInput( tasks: tasks, window: window, lockedIntervals: lockedIntervals, ), ); } void assertTaskIdsUnique(List tasks) { expect(tasks.map((task) => task.id).toSet().length, tasks.length); } void assertIntervalsValid(List tasks) { for (final task in tasks) { final scheduledStart = task.scheduledStart; final scheduledEnd = task.scheduledEnd; expect(scheduledStart == null, scheduledEnd == null, reason: task.id); if (scheduledStart != null && scheduledEnd != null) { expect(scheduledStart.isBefore(scheduledEnd), isTrue, reason: task.id); } final actualStart = task.actualStart; final actualEnd = task.actualEnd; expect(actualStart == null, actualEnd == null, reason: task.id); if (actualStart != null && actualEnd != null) { expect(actualStart.isBefore(actualEnd), isTrue, reason: task.id); } } } void assertNoMovableTaskOverlapsBlockers({ required List resultTasks, required SchedulingInput originalInput, }) { final resultInput = SchedulingInput( tasks: resultTasks, window: originalInput.window, lockedIntervals: originalInput.lockedIntervals, requiredVisibleIntervals: originalInput.requiredVisibleIntervals, ); for (final task in resultInput.movablePlannedFlexibleTasks) { final interval = scheduledInterval(task); if (interval == null || !resultInput.window.contains(interval)) { continue; } for (final blocker in resultInput.blockedIntervals) { expect(interval.overlaps(blocker), isFalse, reason: task.id); } } } void assertImmovablePlacementsUnchanged({ required SchedulingInput before, required List afterTasks, }) { final afterById = {for (final task in afterTasks) task.id: task}; for (final entry in before.occupancyEntries.where((entry) { return entry.task != null && entry.isImmovable; })) { final beforeTask = entry.task!; final afterTask = afterById[beforeTask.id]; expect(afterTask, isNotNull, reason: beforeTask.id); expect(afterTask!.scheduledStart, beforeTask.scheduledStart, reason: beforeTask.id); expect(afterTask.scheduledEnd, beforeTask.scheduledEnd, reason: beforeTask.id); expect(afterTask.actualStart, beforeTask.actualStart, reason: beforeTask.id); expect(afterTask.actualEnd, beforeTask.actualEnd, reason: beforeTask.id); } } void assertFlexibleRelativeOrderPreserved({ required List beforeTasks, required List afterTasks, }) { final beforeOrder = plannedFlexibleIdsByStart(beforeTasks); final afterOrder = plannedFlexibleIdsByStart(afterTasks) .where(beforeOrder.contains) .toList(growable: false); expect(afterOrder, beforeOrder); } List plannedFlexibleIdsByStart(List tasks) { final planned = tasks.where((task) { return task.type == TaskType.flexible && task.status == TaskStatus.planned && task.scheduledStart != null; }).toList(growable: false) ..sort( (left, right) => left.scheduledStart!.compareTo(right.scheduledStart!), ); return planned.map((task) => task.id).toList(growable: false); } void assertTaskSchedulesUnchanged({ required List beforeTasks, required List afterTasks, }) { expect(afterTasks.map((task) => task.id), beforeTasks.map((task) => task.id)); final afterById = {for (final task in afterTasks) task.id: task}; for (final before in beforeTasks) { final after = afterById[before.id]; expect(after, isNotNull, reason: before.id); expect(after!.status, before.status, reason: before.id); expect(after.scheduledStart, before.scheduledStart, reason: before.id); expect(after.scheduledEnd, before.scheduledEnd, reason: before.id); expect(after.actualStart, before.actualStart, reason: before.id); expect(after.actualEnd, before.actualEnd, reason: before.id); } } TimeInterval? scheduledInterval(Task task) { final start = task.scheduledStart; final end = task.scheduledEnd; if (start == null || end == null) { return null; } return TimeInterval(start: start, end: end, label: task.id); } Task backlogTask({ required String id, required DateTime createdAt, required int durationMinutes, }) { return Task.quickCapture( id: id, title: id, createdAt: createdAt, ).copyWith(durationMinutes: durationMinutes); } Task flexibleTask({ required String id, required DateTime createdAt, required DateTime start, required int durationMinutes, }) { return Task.quickCapture( id: id, title: id, createdAt: createdAt, ).copyWith( status: TaskStatus.planned, durationMinutes: durationMinutes, scheduledStart: start, scheduledEnd: start.add(Duration(minutes: durationMinutes)), ); } Task fixedTask({ required String id, required TaskType type, required DateTime createdAt, required DateTime start, required int durationMinutes, }) { return Task( id: id, title: id, projectId: 'fixed', type: type, status: TaskStatus.planned, priority: PriorityLevel.high, durationMinutes: durationMinutes, scheduledStart: start, scheduledEnd: start.add(Duration(minutes: durationMinutes)), createdAt: createdAt, updatedAt: createdAt, ); } class InsertionScenario { const InsertionScenario({ required this.seed, required this.backlogTaskId, required this.input, }); final int seed; final String backlogTaskId; final SchedulingInput input; String describe() { return 'seed=$seed tasks=${input.tasks.length} ' 'locked=${input.lockedIntervals.length} backlog=$backlogTaskId'; } }