301 lines
9.7 KiB
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);
|
|
}
|