import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('OccupancyPolicy', () { const policy = OccupancyPolicy(); final scheduledStart = DateTime(2026, 6, 19, 9); final scheduledEnd = DateTime(2026, 6, 19, 9, 30); final actualStart = DateTime(2026, 6, 19, 9, 5); final actualEnd = DateTime(2026, 6, 19, 9, 25); test('classifies every task type and status combination', () { for (final type in TaskType.values) { for (final status in TaskStatus.values) { final task = matrixTask( type: type, status: status, scheduledStart: scheduledStart, scheduledEnd: scheduledEnd, actualStart: status == TaskStatus.completed ? actualStart : null, actualEnd: status == TaskStatus.completed ? actualEnd : null, ); final entry = policy.classifyTask(task); final expected = expectationFor(type: type, status: status); final reason = '${type.name}/${status.name}'; expect(entry.category, expected.category, reason: reason); expect(entry.isImmovable, expected.isImmovable, reason: reason); expect( entry.blocksAutomaticFlexiblePlacement, expected.blocksAutomaticFlexiblePlacement, reason: reason, ); expect( entry.mayBeExplicitlyOverlappedByRequiredCommitment, expected.mayBeExplicitlyOverlappedByRequiredCommitment, reason: reason, ); expect(entry.reportsConflict, expected.reportsConflict, reason: reason); expect(entry.hiddenByDefault, expected.hiddenByDefault, reason: reason); if (expected.category == OccupancyCategory.nonOccupyingRecord) { expect(entry.interval, isNull, reason: reason); } else { expect(entry.interval, isNotNull, reason: reason); } if (expected.category == OccupancyCategory.completedActualOccupancy) { expect(entry.interval!.start, actualStart, reason: reason); expect(entry.interval!.end, actualEnd, reason: reason); } } } }); test('classifies external locked and required intervals', () { final locked = TimeInterval( start: DateTime(2026, 6, 19, 10), end: DateTime(2026, 6, 19, 11), label: 'locked', ); final required = TimeInterval( start: DateTime(2026, 6, 19, 12), end: DateTime(2026, 6, 19, 13), label: 'required', ); final entries = policy.classify( tasks: const [], lockedIntervals: [locked], requiredVisibleIntervals: [required], ); expect(entries.map((entry) => entry.category), [ OccupancyCategory.hiddenLockedConstraint, OccupancyCategory.requiredVisibleCommitment, ]); expect(entries.first.source, OccupancySource.lockedInterval); expect(entries.first.hiddenByDefault, isTrue); expect(entries.first.blocksAutomaticFlexiblePlacement, isTrue); expect(entries.last.source, OccupancySource.requiredVisibleInterval); expect(entries.last.hiddenByDefault, isFalse); expect(entries.last.blocksAutomaticFlexiblePlacement, isTrue); }); test('treats baseline completed surprise schedule as actual occupancy', () { final surprise = matrixTask( type: TaskType.surprise, status: TaskStatus.completed, scheduledStart: scheduledStart, scheduledEnd: scheduledEnd, ); final entry = policy.classifyTask(surprise); expect(entry.category, OccupancyCategory.completedActualOccupancy); expect(entry.interval!.start, scheduledStart); expect(entry.interval!.end, scheduledEnd); expect(entry.blocksAutomaticFlexiblePlacement, isTrue); }); }); group('Scheduling occupancy integration', () { final now = DateTime(2026, 6, 19, 8); const engine = SchedulingEngine(); test('automatic insertion uses policy blockers without moving them', () { final backlog = Task.quickCapture( id: 'backlog', title: 'Make call', createdAt: now, ).copyWith(durationMinutes: 15); final completedActual = Task( id: 'completed-actual', title: 'Finished appointment', projectId: 'health', type: TaskType.inflexible, status: TaskStatus.completed, priority: PriorityLevel.high, durationMinutes: 30, actualStart: DateTime(2026, 6, 19, 9), actualEnd: DateTime(2026, 6, 19, 9, 30), createdAt: now, updatedAt: now, ); final freeSlot = Task( id: 'rest', title: 'Rest', projectId: 'health', type: TaskType.freeSlot, status: TaskStatus.planned, priority: PriorityLevel.medium, durationMinutes: 30, scheduledStart: DateTime(2026, 6, 19, 9, 30), scheduledEnd: DateTime(2026, 6, 19, 10), createdAt: now, updatedAt: now, ); final result = engine.insertBacklogTaskIntoNextAvailableSlot( input: SchedulingInput( tasks: [backlog, completedActual, freeSlot], window: SchedulingWindow( start: DateTime(2026, 6, 19, 9), end: DateTime(2026, 6, 19, 11), ), ), taskId: 'backlog', updatedAt: now, ); expect(taskById(result.tasks, 'backlog').scheduledStart, DateTime(2026, 6, 19, 10)); expect(taskById(result.tasks, 'backlog').scheduledEnd, DateTime(2026, 6, 19, 10, 15)); expect(taskById(result.tasks, 'completed-actual').actualStart, completedActual.actualStart); expect(taskById(result.tasks, 'rest').scheduledStart, freeSlot.scheduledStart); expect(result.changes.map((change) => change.taskId), ['backlog']); }); }); } Task matrixTask({ required TaskType type, required TaskStatus status, DateTime? scheduledStart, DateTime? scheduledEnd, DateTime? actualStart, DateTime? actualEnd, }) { return Task( id: '${type.name}-${status.name}', title: '${type.name} ${status.name}', projectId: 'matrix', type: type, status: status, priority: PriorityLevel.medium, durationMinutes: 30, scheduledStart: scheduledStart, scheduledEnd: scheduledEnd, actualStart: actualStart, actualEnd: actualEnd, createdAt: DateTime(2026, 6, 19, 8), updatedAt: DateTime(2026, 6, 19, 8), ); } OccupancyExpectation expectationFor({ required TaskType type, required TaskStatus status, }) { if (status == TaskStatus.backlog || status == TaskStatus.cancelled || status == TaskStatus.noLongerRelevant) { return const OccupancyExpectation.nonOccupying(); } if (status == TaskStatus.completed) { return const OccupancyExpectation.blocking( category: OccupancyCategory.completedActualOccupancy, ); } if (status == TaskStatus.missed) { return const OccupancyExpectation.blocking( category: OccupancyCategory.retainedMissedHistoricalInterval, ); } if (type == TaskType.locked) { return const OccupancyExpectation.blocking( category: OccupancyCategory.hiddenLockedConstraint, hiddenByDefault: true, ); } if (type == TaskType.freeSlot) { return const OccupancyExpectation.blocking( category: OccupancyCategory.protectedFreeSlot, mayBeExplicitlyOverlappedByRequiredCommitment: true, ); } if (status == TaskStatus.active) { return const OccupancyExpectation.blocking( category: OccupancyCategory.activeWork, ); } if (type == TaskType.critical || type == TaskType.inflexible) { return const OccupancyExpectation.blocking( category: OccupancyCategory.requiredVisibleCommitment, ); } if (type == TaskType.flexible && status == TaskStatus.planned) { return const OccupancyExpectation.movableFlexible(); } return const OccupancyExpectation.nonOccupying(); } class OccupancyExpectation { const OccupancyExpectation({ required this.category, required this.isImmovable, required this.blocksAutomaticFlexiblePlacement, required this.mayBeExplicitlyOverlappedByRequiredCommitment, required this.reportsConflict, required this.hiddenByDefault, }); const OccupancyExpectation.nonOccupying() : this( category: OccupancyCategory.nonOccupyingRecord, isImmovable: false, blocksAutomaticFlexiblePlacement: false, mayBeExplicitlyOverlappedByRequiredCommitment: false, reportsConflict: false, hiddenByDefault: false, ); const OccupancyExpectation.movableFlexible() : this( category: OccupancyCategory.movablePlannedFlexible, isImmovable: false, blocksAutomaticFlexiblePlacement: false, mayBeExplicitlyOverlappedByRequiredCommitment: false, reportsConflict: false, hiddenByDefault: false, ); const OccupancyExpectation.blocking({ required OccupancyCategory category, bool mayBeExplicitlyOverlappedByRequiredCommitment = false, bool hiddenByDefault = false, }) : this( category: category, isImmovable: true, blocksAutomaticFlexiblePlacement: true, mayBeExplicitlyOverlappedByRequiredCommitment: mayBeExplicitlyOverlappedByRequiredCommitment, reportsConflict: true, hiddenByDefault: hiddenByDefault, ); final OccupancyCategory category; final bool isImmovable; final bool blocksAutomaticFlexiblePlacement; final bool mayBeExplicitlyOverlappedByRequiredCommitment; final bool reportsConflict; final bool hiddenByDefault; } Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); }