forked from eva/focus-flow
537 lines
16 KiB
Dart
537 lines
16 KiB
Dart
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 = <Task>[];
|
|
final lockedIntervals = <TimeInterval>[];
|
|
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 = <Task>[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 = <TimeInterval>[
|
|
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<Task> tasks) {
|
|
expect(tasks.map((task) => task.id).toSet().length, tasks.length);
|
|
}
|
|
|
|
void assertIntervalsValid(List<Task> 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<Task> 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<Task> 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<Task> beforeTasks,
|
|
required List<Task> afterTasks,
|
|
}) {
|
|
final beforeOrder = plannedFlexibleIdsByStart(beforeTasks);
|
|
final afterOrder = plannedFlexibleIdsByStart(afterTasks)
|
|
.where(beforeOrder.contains)
|
|
.toList(growable: false);
|
|
|
|
expect(afterOrder, beforeOrder);
|
|
}
|
|
|
|
List<String> plannedFlexibleIdsByStart(List<Task> 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<Task> beforeTasks,
|
|
required List<Task> 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';
|
|
}
|
|
}
|