feat(scheduling): add structured outcomes and invariants
This commit is contained in:
parent
6219706ab3
commit
18a34778b6
12 changed files with 910 additions and 33 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# V1 Block 12 — Occupancy Policy and Scheduling Correctness
|
||||
|
||||
Status: In progress
|
||||
Status: Complete on 2026-06-25.
|
||||
|
||||
Purpose: Make every scheduling operation use one explicit occupancy policy so
|
||||
protected free slots, surprise work, required commitments, and hidden locked
|
||||
|
|
@ -147,6 +147,8 @@ message contracts and adding invariant/property tests.
|
|||
|
||||
Recommended Codex level: extra high
|
||||
|
||||
Status: Complete on 2026-06-25.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Replace English notice text as the machine contract with stable scheduling
|
||||
|
|
@ -185,6 +187,13 @@ Acceptance criteria:
|
|||
- No-slot and conflict results are non-destructive.
|
||||
- The standard verification suite and new scheduling matrix pass.
|
||||
|
||||
Verification on 2026-06-25:
|
||||
|
||||
- `dart format lib test`: passed, 0 files changed
|
||||
- `dart analyze`: passed, no issues found
|
||||
- `dart test`: passed, 203 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
BREAKPOINT: Stop here. Confirm `high` mode before beginning Block 13.
|
||||
|
||||
Commit suggestion:
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Current Software Plan
|
||||
|
||||
Blocks 01–10 are complete and remain historical records in
|
||||
`Codex Documentation/Archived plans/`. The active path resumes at Block 11.
|
||||
Blocks 01–12 are complete and remain historical records in
|
||||
`Codex Documentation/Archived plans/`. The active path resumes at Block 13.
|
||||
|
||||
The backend sequence is Blocks 11–17. Block 18 is a deliberately limited UI
|
||||
foundation block and is blocked until the backend completion gate in Chunk 17.4
|
||||
|
|
@ -46,7 +46,7 @@ runtime database dependency.
|
|||
| Order | Plan | Status | Backend/UI |
|
||||
|---|---|---|---|
|
||||
| 11 | `../Archived plans/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md` | Complete, archived | Backend |
|
||||
| 12 | `V1_BLOCK_12_Occupancy_Scheduling_Correctness.md` | Planned | Backend |
|
||||
| 12 | `../Archived plans/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md` | Complete, archived | Backend |
|
||||
| 13 | `V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md` | Planned | Backend |
|
||||
| 14 | `V1_BLOCK_14_Application_Use_Cases_Read_Models.md` | Planned | Backend |
|
||||
| 15 | `V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md` | Planned | Backend |
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ passed. The current suite has 143 passing tests.
|
|||
| Core task model | Task types, lifecycle statuses, basic metadata, immutable copy pattern | Strong invariants, explicit nullable clearing, completion metadata, task reminder override, backlog-entered time, typed validation | 11, 13 |
|
||||
| Status semantics | Planned/active/completed/missed/cancelled/no-longer-relevant/backlog | Reconcile the human specification’s `pushed` and `skipped` labels with event/stat semantics without importing V2 burnout behavior | 11, 13 |
|
||||
| Time model | DateTime-based schedule intervals and recurring wall-clock helpers | Civil-date/wall-time/time-zone semantics, DST policy, deterministic clocks, and safe date-only persistence | 11 |
|
||||
| Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis | One centralized occupancy policy; free-slot protection; surprise/actual occupancy across every operation; structured non-localized result codes | 12 |
|
||||
| Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis, centralized occupancy, free-slot protection, surprise/actual occupancy, structured non-localized result codes, invariant coverage | Application-facing orchestration and final backend acceptance scenarios | 14, 17 |
|
||||
| Locked time | Recurring blocks, one-day remove/replace/add overrides, hidden overlay mapping | Strong override validation, date/time-zone safety, complete persistence mapping, date-scoped repository queries | 11, 15 |
|
||||
| Free slots | Task type and timeline token exist | Creation/update rules, protection from flexible scheduling, reminder suppression policy, overlap tests | 12, 13, 14 |
|
||||
| Surprise work | Completed surprise logging and immediate flexible-task repair | Persist actual occupancy, prevent later overlap/regression, idempotent replays, completion/locked-hour statistics | 12, 13, 14 |
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ changes from accidental API drift.
|
|||
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
|
||||
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
|
||||
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
||||
| `src/scheduling_engine.dart` | `SchedulingNoticeType` |
|
||||
| `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` |
|
||||
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
||||
| `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` |
|
||||
| `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` |
|
||||
|
|
@ -167,6 +167,19 @@ Chunk 12.3 intentionally changed the public API:
|
|||
- Surprise tasks with known time now populate `actualStart` and `actualEnd` in
|
||||
addition to the existing scheduled interval compatibility fields.
|
||||
|
||||
Chunk 12.4 intentionally changed the public API:
|
||||
|
||||
- Added stable scheduler code enums: `SchedulingOperationCode`,
|
||||
`SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, and
|
||||
`SchedulingConflictCode`.
|
||||
- Added `SchedulingResult.operationCode` and `SchedulingResult.outcomeCode`.
|
||||
- Added `SchedulingNotice.issueCode`, `SchedulingNotice.movementCode`,
|
||||
`SchedulingNotice.conflictCode`, and immutable `SchedulingNotice.parameters`.
|
||||
- Changed `SchedulingNotice` from a `const` constructor to a runtime constructor
|
||||
so notice parameters can be defensively copied.
|
||||
- Added a `SchedulingResult` assertion that each notice uses at most one issue,
|
||||
movement, or conflict code.
|
||||
|
||||
## Repository contracts
|
||||
|
||||
The current repository surface is pure Dart and in-memory only:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ Verification result on 2026-06-24:
|
|||
- `dart test`: passed, 143 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
Latest Block 12 verification result on 2026-06-25:
|
||||
|
||||
- `dart format lib test`: passed, 0 files changed
|
||||
- `dart analyze`: passed, no issues found
|
||||
- `dart test`: passed, 203 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
Status values:
|
||||
|
||||
- `complete`: Current backend APIs and tests cover the requirement.
|
||||
|
|
@ -37,14 +44,14 @@ Status values:
|
|||
|---|---|---|---|---|---|
|
||||
| MVP-AC-01 | Create a task through quick capture with only a title. | `Task.quickCapture`, `QuickCaptureRequest`, `QuickCaptureService.capture` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Keep constructor invariants under Chunk 11.3. |
|
||||
| MVP-AC-02 | Store quick-capture tasks in Backlog with neutral defaults. | `Task.quickCapture`, `QuickCaptureService.capture`, `TaskStatus.backlog`, `PriorityLevel.medium`, `RewardLevel.notSet`, inbox project default | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Backlog-entered timestamp remains incomplete; see Blocks 11 and 15. |
|
||||
| MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Occupancy centralization and free-slot protection remain in Block 12. |
|
||||
| MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot avoidance, structured no-slot outcomes, and invariant coverage. |
|
||||
| MVP-AC-04 | Create recurring hidden locked blocks and one-day overrides. | `LockedBlock`, `LockedBlockRecurrence.weekly`, `LockedBlockOverride.remove`, `LockedBlockOverride.replace`, `LockedBlockOverride.add`, `expandLockedBlocksForDay` | `test/scheduling_engine_test.dart`, `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Date-only/time-zone hardening remains in Chunk 11.4 and Block 15. |
|
||||
| MVP-AC-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart` | incomplete | Backend read-model tokens exist; actual Flutter rendering is Block 18. |
|
||||
| MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; Flutter UI remains Block 18. |
|
||||
| MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. |
|
||||
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart` | complete | Chunk 12.1 centralized occupancy; Chunk 12.2 covers protected Free Slot blockers. |
|
||||
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Domain movement exists; durable next-open rollover notice/read model remains in Block 14. |
|
||||
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult` | `test/surprise_task_logging_test.dart` | incomplete | Chunk 12.3 covers actual occupancy, same-day blockers, privacy-safe locked overlap reporting, and idempotent retry; statistics/application use cases remain in Blocks 13 and 14. |
|
||||
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. |
|
||||
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. |
|
||||
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers actual occupancy, same-day blockers, privacy-safe locked overlap reporting, typed outcomes, and idempotent retry; statistics/application use cases remain in Blocks 13 and 14. |
|
||||
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskView`, child creation helpers | `test/child_tasks_test.dart` | complete | Atomic application use case remains in Block 14. |
|
||||
| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. |
|
||||
| MVP-AC-13 | Display backlog staleness icons without per-task stale prompts. | `BacklogStalenessMarker`, `BacklogStalenessSettings`, `BacklogView` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Backend marker exists; settings persistence and UI display remain in Blocks 14, 15, and 18. |
|
||||
|
|
@ -54,15 +61,15 @@ Status values:
|
|||
|
||||
| ID | Requirement | Production API surface | Representative tests | Status | Active-plan reference or issue |
|
||||
|---|---|---|---|---|---|
|
||||
| MVP-SUP-01 | Flexible tasks move while locked, inflexible, and critical time does not move automatically. | `OccupancyPolicy`, `SchedulingInput.blockedIntervals`, `SchedulingEngine` placement methods | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart` | complete | Centralized by Chunk 12.1; later Block 12 chunks add more outcome structure and edge-case coverage. |
|
||||
| MVP-SUP-01 | Flexible tasks move while locked, inflexible, and critical time does not move automatically. | `OccupancyPolicy`, `SchedulingInput.blockedIntervals`, `SchedulingEngine` placement methods | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 centralizes occupancy and adds structured outcomes plus invariant coverage for immovable placements. |
|
||||
| MVP-SUP-02 | Critical missed tasks are marked missed and moved to backlog. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.critical` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Durable completion/missed metadata remains in Block 13. |
|
||||
| MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. |
|
||||
| MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. |
|
||||
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart` | incomplete | Chunk 12.2 covers scheduling protection, helpers, and explicit required conflicts; reminder directives remain in Block 13. |
|
||||
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers scheduling protection, helpers, explicit required conflicts, and typed outcomes; reminder directives remain in Block 13. |
|
||||
| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart` | incomplete | Task override storage exists; effective resolver, learned suggestions, and reminder directives remain in Block 13. |
|
||||
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. |
|
||||
| MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. |
|
||||
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | incomplete | Canonical transition/activity layer remains in Block 13. |
|
||||
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, `SchedulingMovementCode`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Structured movement codes exist; canonical transition/activity layer remains in Block 13. |
|
||||
| MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` excludes both; movement and burnout compatibility are modeled as activity/stat metadata. | `test/domain_contracts_test.dart` | contradictory | Resolved by `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`; the contradiction is kept visible so later chunks do not add movement labels as statuses. |
|
||||
|
||||
## Intentionally deferred human-spec items
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@ class FreeSlotService {
|
|||
'Task was not found.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: taskId,
|
||||
issueCode: SchedulingIssueCode.taskNotFound,
|
||||
),
|
||||
outcomeCode: SchedulingOutcomeCode.notFound,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +167,9 @@ class FreeSlotService {
|
|||
'Only critical or inflexible tasks can be explicitly scheduled here.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.invalidTaskState,
|
||||
),
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +212,18 @@ class FreeSlotService {
|
|||
? SchedulingNoticeType.moved
|
||||
: SchedulingNoticeType.overlap,
|
||||
taskId: task.id,
|
||||
movementCode: conflicts.isEmpty
|
||||
? SchedulingMovementCode.requiredCommitmentScheduled
|
||||
: null,
|
||||
conflictCode: conflicts.isEmpty
|
||||
? null
|
||||
: SchedulingConflictCode
|
||||
.requiredCommitmentOverlapsProtectedFreeSlot,
|
||||
parameters: {
|
||||
'requiredStart': requiredInterval.start,
|
||||
'requiredEnd': requiredInterval.end,
|
||||
if (conflicts.isNotEmpty) 'freeSlotCount': conflicts.length,
|
||||
},
|
||||
),
|
||||
];
|
||||
final changes = <SchedulingChange>[
|
||||
|
|
@ -229,6 +245,10 @@ class FreeSlotService {
|
|||
protectedFreeSlotConflicts: conflicts,
|
||||
schedulingResult: SchedulingResult(
|
||||
tasks: updatedTasks,
|
||||
operationCode: SchedulingOperationCode.scheduleRequiredCommitment,
|
||||
outcomeCode: conflicts.isEmpty
|
||||
? SchedulingOutcomeCode.success
|
||||
: SchedulingOutcomeCode.conflict,
|
||||
notices: notices,
|
||||
changes: changes,
|
||||
overlaps: overlapDetails,
|
||||
|
|
@ -241,12 +261,15 @@ RequiredCommitmentScheduleResult _unchangedRequiredResult({
|
|||
required SchedulingInput input,
|
||||
required RequiredCommitmentScheduleStatus status,
|
||||
required SchedulingNotice notice,
|
||||
required SchedulingOutcomeCode outcomeCode,
|
||||
}) {
|
||||
return RequiredCommitmentScheduleResult(
|
||||
status: status,
|
||||
protectedFreeSlotConflicts: const [],
|
||||
schedulingResult: SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.scheduleRequiredCommitment,
|
||||
outcomeCode: outcomeCode,
|
||||
notices: [notice],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,91 @@ enum SchedulingNoticeType {
|
|||
overflow,
|
||||
}
|
||||
|
||||
/// Stable scheduler operation identifiers.
|
||||
enum SchedulingOperationCode {
|
||||
/// Operation was built outside the core scheduler or is not specified.
|
||||
unspecified,
|
||||
|
||||
/// Insert a backlog task into the next available slot.
|
||||
insertBacklogTaskIntoNextAvailableSlot,
|
||||
|
||||
/// Push a flexible task later in the current window.
|
||||
pushFlexibleTaskToNextAvailableSlot,
|
||||
|
||||
/// Move a flexible task to the top of a future/tomorrow queue.
|
||||
pushFlexibleTaskToTomorrowTopOfQueue,
|
||||
|
||||
/// Roll unfinished flexible tasks into a target window.
|
||||
rollOverUnfinishedFlexibleTasks,
|
||||
|
||||
/// Analyze overlaps without moving tasks.
|
||||
analyzeSchedule,
|
||||
|
||||
/// Move a flexible task to backlog through the action service.
|
||||
moveFlexibleTaskToBacklog,
|
||||
|
||||
/// Log completed surprise work.
|
||||
logSurpriseTask,
|
||||
|
||||
/// Explicitly schedule a critical or inflexible commitment.
|
||||
scheduleRequiredCommitment,
|
||||
}
|
||||
|
||||
/// Stable high-level outcome categories for scheduling operations.
|
||||
enum SchedulingOutcomeCode {
|
||||
/// Operation completed without conflicts.
|
||||
success,
|
||||
|
||||
/// Operation intentionally made no changes.
|
||||
noOp,
|
||||
|
||||
/// Requested record was not found.
|
||||
notFound,
|
||||
|
||||
/// Requested operation does not apply to the current state.
|
||||
invalidState,
|
||||
|
||||
/// No placement slot exists for the requested item.
|
||||
noSlot,
|
||||
|
||||
/// The requested queue would extend beyond the planning window.
|
||||
overflow,
|
||||
|
||||
/// Operation completed or analyzed with a conflict.
|
||||
conflict,
|
||||
}
|
||||
|
||||
/// Stable issue identifiers for validation, no-slot, and no-op notices.
|
||||
enum SchedulingIssueCode {
|
||||
taskNotFound,
|
||||
invalidTaskState,
|
||||
missingDuration,
|
||||
missingScheduledSlot,
|
||||
nonPositiveDuration,
|
||||
noAvailableSlot,
|
||||
unfinishedTasksCouldNotFit,
|
||||
noUnfinishedFlexibleTasks,
|
||||
duplicateSurpriseLog,
|
||||
}
|
||||
|
||||
/// Stable movement identifiers for task placement changes.
|
||||
enum SchedulingMovementCode {
|
||||
backlogTaskInserted,
|
||||
flexibleTaskMovedToMakeRoom,
|
||||
flexibleTaskPushedToNextAvailableSlot,
|
||||
flexibleTaskMovedToTomorrow,
|
||||
unfinishedFlexibleTasksRolledOver,
|
||||
flexibleTaskMovedToBacklog,
|
||||
requiredCommitmentScheduled,
|
||||
}
|
||||
|
||||
/// Stable conflict identifiers for overlap/interrupt notices.
|
||||
enum SchedulingConflictCode {
|
||||
flexibleTaskOverlapsBlockedTime,
|
||||
surpriseTaskOverlapsRequiredVisibleTime,
|
||||
requiredCommitmentOverlapsProtectedFreeSlot,
|
||||
}
|
||||
|
||||
/// Window of time available to a scheduling operation.
|
||||
///
|
||||
/// Most engine methods operate on one planning window: "today", "tomorrow",
|
||||
|
|
@ -239,11 +324,15 @@ class SchedulingOverlap {
|
|||
/// carries both text and a structured [type] so the UI can decide whether to show
|
||||
/// it as neutral info, movement, overlap, or failure.
|
||||
class SchedulingNotice {
|
||||
const SchedulingNotice(
|
||||
SchedulingNotice(
|
||||
this.message, {
|
||||
this.type = SchedulingNoticeType.info,
|
||||
this.taskId,
|
||||
});
|
||||
this.issueCode,
|
||||
this.movementCode,
|
||||
this.conflictCode,
|
||||
Map<String, Object?> parameters = const <String, Object?>{},
|
||||
}) : parameters = Map<String, Object?>.unmodifiable(parameters);
|
||||
|
||||
/// Human-readable message safe to surface in UI or logs.
|
||||
final String message;
|
||||
|
|
@ -254,6 +343,18 @@ class SchedulingNotice {
|
|||
/// Optional task related to this notice. Null means the notice applies to the
|
||||
/// whole operation.
|
||||
final String? taskId;
|
||||
|
||||
/// Stable issue code for validation/no-slot/no-op outcomes.
|
||||
final SchedulingIssueCode? issueCode;
|
||||
|
||||
/// Stable movement code for placement changes.
|
||||
final SchedulingMovementCode? movementCode;
|
||||
|
||||
/// Stable conflict code for overlap outcomes.
|
||||
final SchedulingConflictCode? conflictCode;
|
||||
|
||||
/// Structured notice parameters for callers that need more context.
|
||||
final Map<String, Object?> parameters;
|
||||
}
|
||||
|
||||
/// Starter result wrapper for scheduling operations.
|
||||
|
|
@ -264,10 +365,16 @@ class SchedulingNotice {
|
|||
class SchedulingResult {
|
||||
SchedulingResult({
|
||||
required List<Task> tasks,
|
||||
this.operationCode = SchedulingOperationCode.unspecified,
|
||||
this.outcomeCode = SchedulingOutcomeCode.success,
|
||||
List<SchedulingNotice> notices = const <SchedulingNotice>[],
|
||||
List<SchedulingChange> changes = const <SchedulingChange>[],
|
||||
List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
|
||||
}) : tasks = List<Task>.unmodifiable(tasks),
|
||||
assert(
|
||||
_noticeCodesAreSinglePurpose(notices),
|
||||
'Each scheduling notice may have only one issue/movement/conflict code.',
|
||||
),
|
||||
notices = List<SchedulingNotice>.unmodifiable(notices),
|
||||
changes = List<SchedulingChange>.unmodifiable(changes),
|
||||
overlaps = List<SchedulingOverlap>.unmodifiable(overlaps);
|
||||
|
|
@ -275,6 +382,12 @@ class SchedulingResult {
|
|||
/// Replacement task list after the operation.
|
||||
final List<Task> tasks;
|
||||
|
||||
/// Stable operation identifier.
|
||||
final SchedulingOperationCode operationCode;
|
||||
|
||||
/// Stable high-level outcome category.
|
||||
final SchedulingOutcomeCode outcomeCode;
|
||||
|
||||
/// Human-readable operation messages.
|
||||
final List<SchedulingNotice> notices;
|
||||
|
||||
|
|
@ -285,6 +398,21 @@ class SchedulingResult {
|
|||
final List<SchedulingOverlap> overlaps;
|
||||
}
|
||||
|
||||
bool _noticeCodesAreSinglePurpose(Iterable<SchedulingNotice> notices) {
|
||||
for (final notice in notices) {
|
||||
final codeCount = [
|
||||
notice.issueCode,
|
||||
notice.movementCode,
|
||||
notice.conflictCode,
|
||||
].where((code) => code != null).length;
|
||||
if (codeCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Starter scheduling engine.
|
||||
///
|
||||
/// The engine is a pure domain service: it receives immutable-ish input values
|
||||
|
|
@ -335,7 +463,11 @@ class SchedulingEngine {
|
|||
'Task was not found.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: taskId,
|
||||
issueCode: SchedulingIssueCode.taskNotFound,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.notFound,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -346,7 +478,11 @@ class SchedulingEngine {
|
|||
'Only backlog flexible tasks can be inserted.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.invalidTaskState,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +494,11 @@ class SchedulingEngine {
|
|||
'Task needs a positive duration before scheduling.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.missingDuration,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -377,7 +517,11 @@ class SchedulingEngine {
|
|||
'No available flexible slot today.',
|
||||
type: SchedulingNoticeType.overflow,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.noAvailableSlot,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.noSlot,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -413,7 +557,11 @@ class SchedulingEngine {
|
|||
'Task was not found.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: taskId,
|
||||
issueCode: SchedulingIssueCode.taskNotFound,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.notFound,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +572,11 @@ class SchedulingEngine {
|
|||
'Only planned flexible tasks can be pushed.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.invalidTaskState,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -436,7 +588,11 @@ class SchedulingEngine {
|
|||
'Task needs a current scheduled slot before pushing.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.missingScheduledSlot,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -447,7 +603,11 @@ class SchedulingEngine {
|
|||
'Task needs a positive scheduled duration before pushing.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.nonPositiveDuration,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -464,7 +624,11 @@ class SchedulingEngine {
|
|||
'No available flexible slot today.',
|
||||
type: SchedulingNoticeType.overflow,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.noAvailableSlot,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.noSlot,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -473,6 +637,10 @@ class SchedulingEngine {
|
|||
pushedTask: task,
|
||||
placement: placement,
|
||||
pushedTaskMessage: 'Flexible task pushed to next available slot.',
|
||||
pushedTaskMovementCode:
|
||||
SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot,
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
countPushedTaskAsManual: countAsManualPush,
|
||||
);
|
||||
|
|
@ -498,7 +666,11 @@ class SchedulingEngine {
|
|||
'Task was not found.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: taskId,
|
||||
issueCode: SchedulingIssueCode.taskNotFound,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
||||
outcomeCode: SchedulingOutcomeCode.notFound,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -509,7 +681,11 @@ class SchedulingEngine {
|
|||
'Only planned flexible tasks can be moved to tomorrow.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.invalidTaskState,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -522,7 +698,11 @@ class SchedulingEngine {
|
|||
'Task needs a positive duration before moving to tomorrow.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.missingDuration,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -539,7 +719,11 @@ class SchedulingEngine {
|
|||
'No available flexible slot tomorrow.',
|
||||
type: SchedulingNoticeType.overflow,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.noAvailableSlot,
|
||||
),
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
||||
outcomeCode: SchedulingOutcomeCode.noSlot,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -548,6 +732,10 @@ class SchedulingEngine {
|
|||
pushedTask: task,
|
||||
placement: placement,
|
||||
pushedTaskMessage: 'Flexible task moved to tomorrow.',
|
||||
pushedTaskMovementCode:
|
||||
SchedulingMovementCode.flexibleTaskMovedToTomorrow,
|
||||
operationCode:
|
||||
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
);
|
||||
}
|
||||
|
|
@ -607,8 +795,13 @@ class SchedulingEngine {
|
|||
if (rolledItems.isEmpty) {
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
notices: const [
|
||||
SchedulingNotice('No unfinished flexible tasks to roll over.'),
|
||||
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
||||
outcomeCode: SchedulingOutcomeCode.noOp,
|
||||
notices: [
|
||||
SchedulingNotice(
|
||||
'No unfinished flexible tasks to roll over.',
|
||||
issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -622,10 +815,13 @@ class SchedulingEngine {
|
|||
if (placement == null) {
|
||||
return _unchangedResult(
|
||||
input,
|
||||
const SchedulingNotice(
|
||||
SchedulingNotice(
|
||||
'Unfinished flexible tasks could not fit tomorrow.',
|
||||
type: SchedulingNoticeType.overflow,
|
||||
issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit,
|
||||
),
|
||||
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
||||
outcomeCode: SchedulingOutcomeCode.overflow,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -675,6 +871,12 @@ class SchedulingEngine {
|
|||
'Flexible task overlaps blocked time.',
|
||||
type: SchedulingNoticeType.overlap,
|
||||
taskId: task.id,
|
||||
conflictCode:
|
||||
SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
|
||||
parameters: {
|
||||
'blockedStart': blockedInterval.start,
|
||||
'blockedEnd': blockedInterval.end,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -682,6 +884,10 @@ class SchedulingEngine {
|
|||
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.analyzeSchedule,
|
||||
outcomeCode: overlaps.isEmpty
|
||||
? SchedulingOutcomeCode.success
|
||||
: SchedulingOutcomeCode.conflict,
|
||||
notices: List<SchedulingNotice>.unmodifiable(notices),
|
||||
overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
|
||||
);
|
||||
|
|
@ -810,10 +1016,14 @@ List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
|
|||
/// schedule while showing why the requested action did not apply.
|
||||
SchedulingResult _unchangedResult(
|
||||
SchedulingInput input,
|
||||
SchedulingNotice notice,
|
||||
) {
|
||||
SchedulingNotice notice, {
|
||||
SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified,
|
||||
SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState,
|
||||
}) {
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: operationCode,
|
||||
outcomeCode: outcomeCode,
|
||||
notices: [notice],
|
||||
);
|
||||
}
|
||||
|
|
@ -1162,12 +1372,24 @@ SchedulingResult _applyPlacement({
|
|||
: 'Flexible task moved to make room.',
|
||||
type: SchedulingNoticeType.moved,
|
||||
taskId: task.id,
|
||||
movementCode: isInsertedTask
|
||||
? SchedulingMovementCode.backlogTaskInserted
|
||||
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
|
||||
parameters: {
|
||||
'previousStart': task.scheduledStart,
|
||||
'previousEnd': task.scheduledEnd,
|
||||
'nextStart': interval.start,
|
||||
'nextEnd': interval.end,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SchedulingResult(
|
||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||
operationCode:
|
||||
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
||||
outcomeCode: SchedulingOutcomeCode.success,
|
||||
notices: List<SchedulingNotice>.unmodifiable(notices),
|
||||
changes: List<SchedulingChange>.unmodifiable(changes),
|
||||
);
|
||||
|
|
@ -1183,6 +1405,8 @@ SchedulingResult _applyPushPlacement({
|
|||
required Task pushedTask,
|
||||
required _BacklogInsertionPlan placement,
|
||||
required String pushedTaskMessage,
|
||||
required SchedulingMovementCode pushedTaskMovementCode,
|
||||
required SchedulingOperationCode operationCode,
|
||||
required DateTime updatedAt,
|
||||
bool countPushedTaskAsManual = true,
|
||||
}) {
|
||||
|
|
@ -1230,12 +1454,23 @@ SchedulingResult _applyPushPlacement({
|
|||
isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.',
|
||||
type: SchedulingNoticeType.moved,
|
||||
taskId: task.id,
|
||||
movementCode: isPushedTask
|
||||
? pushedTaskMovementCode
|
||||
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
|
||||
parameters: {
|
||||
'previousStart': task.scheduledStart,
|
||||
'previousEnd': task.scheduledEnd,
|
||||
'nextStart': interval.start,
|
||||
'nextEnd': interval.end,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SchedulingResult(
|
||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||
operationCode: operationCode,
|
||||
outcomeCode: SchedulingOutcomeCode.success,
|
||||
notices: List<SchedulingNotice>.unmodifiable(notices),
|
||||
changes: List<SchedulingChange>.unmodifiable(changes),
|
||||
);
|
||||
|
|
@ -1299,6 +1534,13 @@ SchedulingResult _applyRolloverPlacement({
|
|||
'Flexible task moved to make room.',
|
||||
type: SchedulingNoticeType.moved,
|
||||
taskId: task.id,
|
||||
movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
|
||||
parameters: {
|
||||
'previousStart': task.scheduledStart,
|
||||
'previousEnd': task.scheduledEnd,
|
||||
'nextStart': interval.start,
|
||||
'nextEnd': interval.end,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1309,11 +1551,17 @@ SchedulingResult _applyRolloverPlacement({
|
|||
SchedulingNotice(
|
||||
'$rolledCount unfinished flexible tasks were moved to tomorrow.',
|
||||
type: SchedulingNoticeType.moved,
|
||||
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
|
||||
parameters: {
|
||||
'rolledCount': rolledCount,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return SchedulingResult(
|
||||
tasks: List<Task>.unmodifiable(updatedTasks),
|
||||
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
||||
outcomeCode: SchedulingOutcomeCode.success,
|
||||
notices: List<SchedulingNotice>.unmodifiable(notices),
|
||||
changes: List<SchedulingChange>.unmodifiable(changes),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -331,11 +331,14 @@ class FlexibleTaskActionService {
|
|||
if (task == null) {
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
||||
outcomeCode: SchedulingOutcomeCode.notFound,
|
||||
notices: [
|
||||
SchedulingNotice(
|
||||
'Task was not found.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: taskId,
|
||||
issueCode: SchedulingIssueCode.taskNotFound,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -344,11 +347,14 @@ class FlexibleTaskActionService {
|
|||
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
||||
return SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
||||
outcomeCode: SchedulingOutcomeCode.invalidState,
|
||||
notices: [
|
||||
SchedulingNotice(
|
||||
'Only planned flexible tasks can be moved to backlog.',
|
||||
type: SchedulingNoticeType.noFit,
|
||||
taskId: task.id,
|
||||
issueCode: SchedulingIssueCode.invalidTaskState,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -369,11 +375,20 @@ class FlexibleTaskActionService {
|
|||
return current;
|
||||
}),
|
||||
),
|
||||
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
|
||||
outcomeCode: SchedulingOutcomeCode.success,
|
||||
notices: [
|
||||
SchedulingNotice(
|
||||
'Flexible task moved to backlog.',
|
||||
type: SchedulingNoticeType.moved,
|
||||
taskId: task.id,
|
||||
movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog,
|
||||
parameters: {
|
||||
'previousStart': task.scheduledStart,
|
||||
'previousEnd': task.scheduledEnd,
|
||||
'nextStart': null,
|
||||
'nextEnd': null,
|
||||
},
|
||||
),
|
||||
],
|
||||
changes: [
|
||||
|
|
@ -517,8 +532,14 @@ class SurpriseTaskLogService {
|
|||
surpriseTask: existingTask,
|
||||
schedulingResult: SchedulingResult(
|
||||
tasks: input.tasks,
|
||||
notices: const [
|
||||
SchedulingNotice('Surprise task already logged.'),
|
||||
operationCode: SchedulingOperationCode.logSurpriseTask,
|
||||
outcomeCode: SchedulingOutcomeCode.noOp,
|
||||
notices: [
|
||||
SchedulingNotice(
|
||||
'Surprise task already logged.',
|
||||
issueCode: SchedulingIssueCode.duplicateSurpriseLog,
|
||||
taskId: existingTask.id,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -533,7 +554,9 @@ class SurpriseTaskLogService {
|
|||
surpriseTask: surpriseTask,
|
||||
schedulingResult: SchedulingResult(
|
||||
tasks: List<Task>.unmodifiable(currentTasks),
|
||||
notices: const [
|
||||
operationCode: SchedulingOperationCode.logSurpriseTask,
|
||||
outcomeCode: SchedulingOutcomeCode.success,
|
||||
notices: [
|
||||
SchedulingNotice('Surprise task logged.'),
|
||||
],
|
||||
),
|
||||
|
|
@ -591,6 +614,12 @@ class SurpriseTaskLogService {
|
|||
'Surprise task overlaps required visible time.',
|
||||
type: SchedulingNoticeType.overlap,
|
||||
taskId: overlap.taskId,
|
||||
conflictCode:
|
||||
SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime,
|
||||
parameters: {
|
||||
'blockedStart': overlap.blockedInterval.start,
|
||||
'blockedEnd': overlap.blockedInterval.end,
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
|
@ -599,8 +628,12 @@ class SurpriseTaskLogService {
|
|||
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
|
||||
schedulingResult: SchedulingResult(
|
||||
tasks: List<Task>.unmodifiable(currentTasks),
|
||||
operationCode: SchedulingOperationCode.logSurpriseTask,
|
||||
outcomeCode: requiredOverlaps.isEmpty
|
||||
? SchedulingOutcomeCode.success
|
||||
: SchedulingOutcomeCode.conflict,
|
||||
notices: List<SchedulingNotice>.unmodifiable([
|
||||
const SchedulingNotice('Surprise task logged.'),
|
||||
SchedulingNotice('Surprise task logged.'),
|
||||
...movementNotices,
|
||||
...overlapNotices,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ void main() {
|
|||
window: window,
|
||||
tasks: [scheduledTask],
|
||||
lockedIntervals: [lockedInterval],
|
||||
notices: const [
|
||||
notices: [
|
||||
SchedulingNotice('Saved schedule'),
|
||||
],
|
||||
operationName: 'load-day',
|
||||
|
|
|
|||
|
|
@ -1141,7 +1141,7 @@ void main() {
|
|||
);
|
||||
final result = SchedulingResult(
|
||||
tasks: [moved],
|
||||
notices: const [
|
||||
notices: [
|
||||
SchedulingNotice(
|
||||
'Flexible task moved.',
|
||||
type: SchedulingNoticeType.moved,
|
||||
|
|
@ -1567,7 +1567,10 @@ void main() {
|
|||
'task-1',
|
||||
'tomorrow-flexible-1',
|
||||
]);
|
||||
expect(result.notices.first.message, 'Flexible task moved to tomorrow.');
|
||||
expect(
|
||||
result.notices.first.movementCode,
|
||||
SchedulingMovementCode.flexibleTaskMovedToTomorrow,
|
||||
);
|
||||
});
|
||||
|
||||
test('push flexible task to tomorrow respects required and locked time',
|
||||
|
|
@ -1667,9 +1670,10 @@ void main() {
|
|||
'tomorrow-flexible-1',
|
||||
]);
|
||||
expect(
|
||||
result.notices.first.message,
|
||||
'2 unfinished flexible tasks were moved to tomorrow.',
|
||||
result.notices.first.movementCode,
|
||||
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
|
||||
);
|
||||
expect(result.notices.first.parameters['rolledCount'], 2);
|
||||
});
|
||||
|
||||
test('rollover excludes locked required completed and cancelled tasks', () {
|
||||
|
|
@ -1733,9 +1737,10 @@ void main() {
|
|||
DateTime(2026, 6, 19, 18));
|
||||
expect(result.changes.map((change) => change.taskId), ['flexible-1']);
|
||||
expect(
|
||||
result.notices.first.message,
|
||||
'1 unfinished flexible tasks were moved to tomorrow.',
|
||||
result.notices.first.movementCode,
|
||||
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
|
||||
);
|
||||
expect(result.notices.first.parameters['rolledCount'], 1);
|
||||
});
|
||||
|
||||
test('moveToBacklog clears active schedule placement', () {
|
||||
|
|
|
|||
537
test/scheduling_invariants_test.dart
Normal file
537
test/scheduling_invariants_test.dart
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -312,8 +312,10 @@ void main() {
|
|||
taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart,
|
||||
taskById(first.schedulingResult.tasks, 'flexible').scheduledStart,
|
||||
);
|
||||
expect(retry.schedulingResult.notices.single.message,
|
||||
'Surprise task already logged.');
|
||||
expect(
|
||||
retry.schedulingResult.notices.single.issueCode,
|
||||
SchedulingIssueCode.duplicateSurpriseLog,
|
||||
);
|
||||
});
|
||||
|
||||
test('later insertion avoids persisted surprise actual occupancy', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue