focus-flow/test/occupancy_policy_test.dart

301 lines
9.7 KiB
Dart

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<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}