focus-flow/test/scheduling_invariants_test.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';
}
}