feat(app): add atomic v1 command use cases
This commit is contained in:
parent
11f6eff85b
commit
60cad3814c
8 changed files with 1994 additions and 17 deletions
|
|
@ -34,16 +34,16 @@ passed. The current suite has 143 passing tests.
|
|||
| 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, centralized occupancy, free-slot protection, surprise/actual occupancy, structured non-localized result codes, invariant coverage, Today pending-notice read model | Application-facing command 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, timeline token, creation/update rules, protection from flexible scheduling, reminder suppression policy, and overlap tests | Application use cases that persist resulting changes atomically | 12, 13, 14 |
|
||||
| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 |
|
||||
| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 |
|
||||
| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, push-before-completion values, project aggregates, and learned suggestion inputs | Atomic persistence | 14 |
|
||||
| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 |
|
||||
| Free slots | Task type, timeline token, creation/update rules, protection from flexible scheduling, reminder suppression policy, overlap tests, and atomic create/update/remove application commands | Management/settings queries and future UI rendering | 12, 13, 14, 18 |
|
||||
| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, completion/locked-hour accounting, and atomic application command persistence | Final backend acceptance scenarios | 12, 13, 14, 17 |
|
||||
| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, exactly-once task-stat accounting, and atomic V1 application commands | Management queries and final backend acceptance scenarios | 13, 14, 17 |
|
||||
| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, push-before-completion values, project aggregates, learned suggestion inputs, and atomic command persistence | MongoDB adapter persistence and final backend acceptance scenarios | 14, 15, 16, 17 |
|
||||
| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, idempotent force completion, and atomic application commands | Final backend acceptance scenarios | 13, 14, 17 |
|
||||
| Project defaults | Configured static defaults, reminder profile on project, per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution, and effective reminder-profile resolver | Application-layer persistence/query orchestration | 13, 14 |
|
||||
| Reminder profiles | Gentle/persistent/strict/silent enum on project, task override, effective-profile resolver, typed reminder directives, and Free Slot suppression policy | Platform delivery remains outside the pure core | 13 |
|
||||
| Timeline state | UI-independent item mapper, locked overlay state, compact selection, complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, and pending notice projection | Flutter rendering and later management/read-model queries | 14, 18 |
|
||||
| Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 |
|
||||
| Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, side-effect-free read boundary, complete Today query/read model, and in-memory atomic unit-of-work foundation | V1 command use cases that invoke domain rules and return UI-ready mutation results | 14 |
|
||||
| Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, side-effect-free read boundary, complete Today query/read model, V1 atomic command use cases, and in-memory atomic unit-of-work foundation | Management queries/settings commands and explicit rollover/startup orchestration | 14 |
|
||||
| Repository contracts | Basic task/project/locked/snapshot interfaces, activity/settings/project-statistics/operation repository contracts, and in-memory staged unit of work | Date/project/parent queries, archive/delete behavior, revisions, complete persistence codecs, and adapter conformance suite | 14, 15 |
|
||||
| Document mapping | Task, task-statistics, and project-statistics map round trips; field-name constants for other entities | Complete codecs for every repository entity, explicit stable codes, schema version, civil-time encoding, migration fixtures | 15 |
|
||||
| MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 |
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ Verification:
|
|||
|
||||
Recommended Codex level: extra high
|
||||
|
||||
Status: Complete
|
||||
|
||||
Tasks:
|
||||
|
||||
Implement application commands for:
|
||||
|
|
@ -196,6 +198,40 @@ Acceptance criteria:
|
|||
- Failure tests prove rollback.
|
||||
- Command outputs are sufficient for a UI to refresh predictably.
|
||||
|
||||
Completed implementation:
|
||||
|
||||
- Added `src/application_commands.dart` with `V1ApplicationCommandUseCases`,
|
||||
stable `ApplicationCommandCode` values, child draft input, structured command
|
||||
results, and read refresh hints.
|
||||
- Added atomic commands for quick capture to backlog, quick capture to next
|
||||
available slot, backlog item scheduling, flexible next-slot push, flexible
|
||||
tomorrow/top push, flexible move to backlog, flexible completion, required
|
||||
done/missed/cancel/no-longer-relevant actions, surprise logging with flexible
|
||||
repair, protected Free Slot create/update/remove, child task break-up, child
|
||||
completion, parent completion, and parent-from-child completion.
|
||||
- Commands load authoritative repository state, expand locked intervals through
|
||||
the explicit owner time-zone context where scheduling or completion accounting
|
||||
needs it, and invoke the existing pure domain services.
|
||||
- Commands enforce idempotency through the operation record boundary and use
|
||||
optional `expectedUpdatedAt` guards for V1 stale-write protection until Block
|
||||
15 adds explicit persisted revisions.
|
||||
- Commands persist changed tasks, new activities, project aggregate updates, and
|
||||
operation records in one `ApplicationUnitOfWork.run` boundary.
|
||||
- Command results include changed tasks, activities, project statistics, notices,
|
||||
scheduling changes/overlaps, child ids, protected-rest conflicts, and read
|
||||
hints so UI callers do not need to infer what changed.
|
||||
- Added command tests for quick capture, backlog scheduling, stale revisions,
|
||||
commit rollback, flexible pushes, backlog movement, completion/project stats,
|
||||
surprise repair, Free Slot create/update/remove, child break-up, and
|
||||
parent/child completion propagation.
|
||||
|
||||
Verification:
|
||||
|
||||
- `dart format lib test`: passed, 0 files changed after final format
|
||||
- `dart analyze`: passed, no issues found
|
||||
- `dart test`: passed, 268 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
BREAKPOINT: Stop here. Confirm `high` mode before adding management queries and
|
||||
startup/rollover orchestration.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ The public library entry point is `lib/scheduler_core.dart`. It exports the
|
|||
following source files:
|
||||
|
||||
- `src/models.dart`
|
||||
- `src/application_commands.dart`
|
||||
- `src/application_layer.dart`
|
||||
- `src/backlog.dart`
|
||||
- `src/child_tasks.dart`
|
||||
|
|
@ -41,6 +42,7 @@ changes from accidental API drift.
|
|||
|
||||
| File | Enums |
|
||||
|---|---|
|
||||
| `src/application_commands.dart` | `ApplicationCommandCode` |
|
||||
| `src/application_layer.dart` | `ApplicationFailureCode` |
|
||||
| `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` |
|
||||
| `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` |
|
||||
|
|
@ -61,6 +63,7 @@ changes from accidental API drift.
|
|||
|
||||
| File | Public API |
|
||||
|---|---|
|
||||
| `src/application_commands.dart` | `ApplicationChildTaskDraft`, `ApplicationCommandReadHint`, `ApplicationCommandResult`, `V1ApplicationCommandUseCases` |
|
||||
| `src/application_layer.dart` | `OwnerTimeZoneContext`, `ApplicationOperationContext`, `ApplicationFailure`, `ApplicationResult`, `ApplicationFailureException`, `ApplicationPersistenceException`, `ApplicationOperationRecord`, `OwnerSettings`, `TaskActivityRepository`, `ProjectStatisticsRepository`, `OwnerSettingsRepository`, `ApplicationOperationRepository`, `ApplicationUnitOfWorkRepositories`, `ApplicationUnitOfWork`, `ApplicationSchedulingLoader`, `InMemoryApplicationUnitOfWork` |
|
||||
| `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
|
||||
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
|
||||
|
|
@ -289,6 +292,23 @@ Chunk 14.2 intentionally changed the public API:
|
|||
- Updated compact next-flexible selection so the current flexible item is not
|
||||
also returned as `nextFlexibleItem`.
|
||||
|
||||
Chunk 14.3 intentionally changed the public API:
|
||||
|
||||
- Added `src/application_commands.dart` and exported it from
|
||||
`scheduler_core.dart`.
|
||||
- Added `ApplicationCommandCode`, `ApplicationChildTaskDraft`,
|
||||
`ApplicationCommandReadHint`, `ApplicationCommandResult`, and
|
||||
`V1ApplicationCommandUseCases`.
|
||||
- Added atomic V1 commands for quick capture, backlog scheduling, flexible
|
||||
push/backlog/completion, required task actions, surprise logging, protected
|
||||
Free Slot create/update/remove, child break-up, and parent/child completion.
|
||||
- Command results return changed tasks, persisted activities, project aggregate
|
||||
updates, scheduler notices/changes/overlaps, child ids, and read refresh
|
||||
hints.
|
||||
- Application command failures use stable `ApplicationFailureCode` values for
|
||||
not found, stale revision, no slot, validation, persistence, and duplicate
|
||||
operation outcomes.
|
||||
|
||||
## Repository contracts
|
||||
|
||||
The current repository surface is pure Dart and in-memory only:
|
||||
|
|
|
|||
|
|
@ -77,6 +77,13 @@ Latest Block 14.2 verification result on 2026-06-25:
|
|||
- `dart test`: passed, 257 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
Latest Block 14.3 verification result on 2026-06-25:
|
||||
|
||||
- `dart format lib test`: passed, 0 files changed after final format
|
||||
- `dart analyze`: passed, no issues found
|
||||
- `dart test`: passed, 268 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
Status values:
|
||||
|
||||
- `complete`: Current backend APIs and tests cover the requirement.
|
||||
|
|
@ -91,20 +98,20 @@ Status values:
|
|||
|
||||
| ID | Acceptance criterion | Production API surface | Representative tests | Status | Active-plan reference or issue |
|
||||
|---|---|---|---|---|---|
|
||||
| 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-01 | Create a task through quick capture with only a title. | `V1ApplicationCommandUseCases.quickCaptureToBacklog`, `Task.quickCapture`, `QuickCaptureRequest`, `QuickCaptureService.capture` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds the atomic application command boundary. |
|
||||
| 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`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot avoidance, structured no-slot outcomes, and invariant coverage. |
|
||||
| MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `V1ApplicationCommandUseCases.quickCaptureToNextAvailableSlot`, `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart`, `test/application_commands_test.dart` | complete | Block 12 covers scheduling rules; Chunk 14.3 adds the atomic application command boundary. |
|
||||
| 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. | `GetTodayStateQuery`, `TodayState`, `TodayTimelineItem`, `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart`, `test/today_state_test.dart` | incomplete | Backend Today read model and tokens are complete; actual Flutter rendering is Block 18. |
|
||||
| MVP-AC-06 | Use compact Today mode manually. | `GetTodayStateQuery`, `TodayCompactState`, `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart`, `test/today_state_test.dart` | complete | Complete at backend/read-model level, including current/next deduplication; 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, `TaskActivityCode` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 adds internal activity records; exactly-once statistics/accounting continues in Chunk 13.2. |
|
||||
| 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-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `V1ApplicationCommandUseCases.pushFlexibleToNextAvailableSlot`, `V1ApplicationCommandUseCases.pushFlexibleToTomorrowTopOfQueue`, `V1ApplicationCommandUseCases.moveFlexibleToBacklog`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods, `TaskActivityCode` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic application commands with persisted activities and operation records. |
|
||||
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `V1ApplicationCommandUseCases.scheduleBacklogItemToNextAvailableSlot`, `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`, `test/application_commands_test.dart` | complete | Block 12 covers scheduling rules; Chunk 14.3 adds the atomic application command boundary. |
|
||||
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks`, `SchedulingNotice`, `TodayPendingNotice`, `GetTodayStateQuery` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart`, `test/today_state_test.dart` | incomplete | Domain movement, structured rollover notices, and Today pending-notice read model exist; explicit durable app-open rollover orchestration remains in Chunk 14.5. |
|
||||
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. |
|
||||
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring 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, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. |
|
||||
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `V1ApplicationCommandUseCases.logSurpriseTask`, `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic surprise logging use case with flexible repair, activity persistence, project stats, and operation records. |
|
||||
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `V1ApplicationCommandUseCases.breakUpTask`, `ApplicationChildTaskDraft`, `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic child break-up command wiring. |
|
||||
| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `V1ApplicationCommandUseCases.completeChildTask`, `V1ApplicationCommandUseCases.completeParentTask`, `V1ApplicationCommandUseCases.completeParentFromChild`, `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers, `TaskActivity` | `test/child_tasks_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic parent/child completion command wiring. |
|
||||
| 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. |
|
||||
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `ProjectStatisticsAggregationService`, `ApplicationUnitOfWork`, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart`, `test/child_tasks_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical activities, exactly-once task/project statistic accounting, parent/child completion metadata, and learned suggestion aggregates; Chunk 14.1 adds the atomic in-memory unit-of-work foundation; concrete command integration remains in Chunk 14.3. |
|
||||
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `ProjectStatisticsAggregationService`, `ApplicationUnitOfWork`, `V1ApplicationCommandUseCases`, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart`, `test/child_tasks_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart`, `test/application_commands_test.dart` | complete | Chunks 13.1-13.4 add canonical statistics/accounting; Chunk 14.3 persists command activities and project aggregate updates atomically in memory. MongoDB adapter persistence remains tracked separately under MVP-SUP-07. |
|
||||
|
||||
## Additional MVP backend requirements
|
||||
|
||||
|
|
@ -114,11 +121,11 @@ Status values:
|
|||
| MVP-SUP-02 | Critical missed tasks are marked missed and moved to backlog. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.critical` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 records both missed and moved-to-backlog activities. |
|
||||
| MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_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`, `TaskActivityCode` | `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 centralizes both transitions and emits distinct activity codes. |
|
||||
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart` | complete | Blocks 12 and 13 cover scheduling protection, explicit required conflicts, and reminder suppression/directives at the backend policy level. |
|
||||
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `V1ApplicationCommandUseCases.createProtectedFreeSlot`, `V1ApplicationCommandUseCases.updateProtectedFreeSlot`, `V1ApplicationCommandUseCases.removeProtectedFreeSlot`, `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart`, `test/application_commands_test.dart` | complete | Blocks 12 and 13 cover scheduling/reminder policy; Chunk 14.3 adds atomic protected Free Slot commands. |
|
||||
| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `ReminderPolicyService`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart`, `test/reminder_policy_test.dart` | complete | Chunk 13.5 adds effective reminder resolution without using learned suggestions as silent configuration. |
|
||||
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, `ApplicationUnitOfWork`, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart`, `test/application_layer_test.dart` | incomplete | Chunk 14.1 adds pure Dart application transaction contracts and staged in-memory unit-of-work behavior; complete codecs, revisions, and runtime adapter remain in Blocks 15 and 16. |
|
||||
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, `ApplicationUnitOfWork`, `V1ApplicationCommandUseCases`, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart`, `test/application_layer_test.dart`, `test/application_commands_test.dart` | incomplete | Chunks 14.1-14.3 add pure Dart transaction/read/command contracts; complete codecs, durable revisions, 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`, `GetTodayStateQuery`, `TodayTimelineItem` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart`, `test/today_state_test.dart` | complete | Chunk 14.2 adds hidden-by-default Today omission, explicit reveal mode, and date-stable locked overlay IDs. |
|
||||
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, `ApplicationUnitOfWork`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; Chunk 14.1 adds atomic transaction contracts; concrete action use-case persistence remains in Chunk 14.3. |
|
||||
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, `ApplicationUnitOfWork`, `V1ApplicationCommandUseCases`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic command persistence for movement activities, task stats, project stats, and operation records. |
|
||||
| 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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
// Keep this file small. Implementation details belong in `lib/src/`.
|
||||
|
||||
export 'src/models.dart';
|
||||
export 'src/application_commands.dart';
|
||||
export 'src/application_layer.dart';
|
||||
export 'src/backlog.dart';
|
||||
export 'src/child_tasks.dart';
|
||||
|
|
|
|||
1382
lib/src/application_commands.dart
Normal file
1382
lib/src/application_commands.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -478,6 +478,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
detailCode: error.code.name,
|
||||
),
|
||||
);
|
||||
} on ArgumentError catch (error) {
|
||||
return ApplicationResult.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
detailCode: error.name,
|
||||
),
|
||||
);
|
||||
} on ApplicationPersistenceException {
|
||||
return ApplicationResult.failure(
|
||||
const ApplicationFailure(
|
||||
|
|
@ -511,6 +518,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
detailCode: error.code.name,
|
||||
),
|
||||
);
|
||||
} on ArgumentError catch (error) {
|
||||
return ApplicationResult.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
detailCode: error.name,
|
||||
),
|
||||
);
|
||||
} on ApplicationPersistenceException {
|
||||
return ApplicationResult.failure(
|
||||
const ApplicationFailure(
|
||||
|
|
|
|||
517
test/application_commands_test.dart
Normal file
517
test/application_commands_test.dart
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('V1 application commands', () {
|
||||
final day = CivilDate(2026, 6, 25);
|
||||
final tomorrow = CivilDate(2026, 6, 26);
|
||||
final createdAt = DateTime.utc(2026, 6, 20, 8);
|
||||
|
||||
test('quick capture to backlog is atomic and idempotent by operation id',
|
||||
() async {
|
||||
final store = storeWithSettings();
|
||||
final useCases = commands(store);
|
||||
final context =
|
||||
appContext('capture-backlog', DateTime.utc(2026, 6, 25, 8));
|
||||
|
||||
final result = await useCases.quickCaptureToBacklog(
|
||||
context: context,
|
||||
title: ' Write down idea ',
|
||||
);
|
||||
final duplicate = await useCases.quickCaptureToBacklog(
|
||||
context: context,
|
||||
title: 'Write down idea again',
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(result.requireValue.changedTasks.single.title, 'Write down idea');
|
||||
expect(
|
||||
result.requireValue.changedTasks.single.status, TaskStatus.backlog);
|
||||
expect(store.currentTasks, hasLength(1));
|
||||
expect(store.currentOperations.single.operationId, 'capture-backlog');
|
||||
expect(
|
||||
duplicate.failure!.code, ApplicationFailureCode.duplicateOperation);
|
||||
expect(store.currentTasks, hasLength(1));
|
||||
});
|
||||
|
||||
test('quick capture to next available slot schedules through the app layer',
|
||||
() async {
|
||||
final store = storeWithSettings();
|
||||
|
||||
final result = await commands(store).quickCaptureToNextAvailableSlot(
|
||||
context: appContext('capture-schedule', DateTime.utc(2026, 6, 25, 8)),
|
||||
localDate: day,
|
||||
title: 'Scheduled capture',
|
||||
durationMinutes: 30,
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
final captured = result.requireValue.changedTasks.single;
|
||||
expect(captured.status, TaskStatus.planned);
|
||||
expect(captured.scheduledStart, DateTime.utc(2026, 6, 25, 9));
|
||||
expect(captured.scheduledEnd, DateTime.utc(2026, 6, 25, 9, 30));
|
||||
expect(store.currentTaskActivities.single.code,
|
||||
TaskActivityCode.restoredFromBacklog);
|
||||
});
|
||||
|
||||
test('schedules backlog item and persists movement activities', () async {
|
||||
final backlog = task(
|
||||
id: 'backlog',
|
||||
title: 'Backlog',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.backlog,
|
||||
createdAt: createdAt,
|
||||
durationMinutes: 30,
|
||||
);
|
||||
final planned = task(
|
||||
id: 'planned',
|
||||
title: 'Planned',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 9, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [backlog, planned]);
|
||||
|
||||
final result =
|
||||
await commands(store).scheduleBacklogItemToNextAvailableSlot(
|
||||
context: appContext('schedule-backlog', DateTime.utc(2026, 6, 25, 8)),
|
||||
localDate: day,
|
||||
taskId: backlog.id,
|
||||
expectedUpdatedAt: backlog.updatedAt,
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(result.requireValue.changedTasks.map((task) => task.id).toSet(), {
|
||||
'backlog',
|
||||
'planned',
|
||||
});
|
||||
expect(
|
||||
taskById(store.currentTasks, 'backlog').status,
|
||||
TaskStatus.planned,
|
||||
);
|
||||
expect(
|
||||
taskById(store.currentTasks, 'backlog').scheduledStart,
|
||||
DateTime.utc(2026, 6, 25, 9),
|
||||
);
|
||||
expect(
|
||||
taskById(store.currentTasks, 'planned').scheduledStart,
|
||||
DateTime.utc(2026, 6, 25, 9, 30),
|
||||
);
|
||||
expect(store.currentTaskActivities.map((activity) => activity.code), [
|
||||
TaskActivityCode.restoredFromBacklog,
|
||||
TaskActivityCode.automaticallyPushed,
|
||||
]);
|
||||
});
|
||||
|
||||
test('stale expected update leaves repositories unchanged', () async {
|
||||
final planned = task(
|
||||
id: 'planned',
|
||||
title: 'Planned',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 9, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [planned]);
|
||||
|
||||
final result = await commands(store).moveFlexibleToBacklog(
|
||||
context: appContext('stale-move', DateTime.utc(2026, 6, 25, 8)),
|
||||
taskId: planned.id,
|
||||
expectedUpdatedAt:
|
||||
planned.updatedAt.subtract(const Duration(minutes: 1)),
|
||||
);
|
||||
|
||||
expect(result.failure!.code, ApplicationFailureCode.staleRevision);
|
||||
expect(
|
||||
taskById(store.currentTasks, planned.id).status, TaskStatus.planned);
|
||||
expect(store.currentOperations, isEmpty);
|
||||
});
|
||||
|
||||
test('commit persistence failure rolls back command-side task and activity',
|
||||
() async {
|
||||
final planned = task(
|
||||
id: 'planned',
|
||||
title: 'Planned',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 9, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [planned])
|
||||
..failNextCommitForTesting();
|
||||
|
||||
final result = await commands(store).completeFlexibleTask(
|
||||
context: appContext('rollback-complete', DateTime.utc(2026, 6, 25, 9)),
|
||||
localDate: day,
|
||||
taskId: planned.id,
|
||||
);
|
||||
|
||||
expect(result.failure!.code, ApplicationFailureCode.persistenceFailure);
|
||||
expect(
|
||||
taskById(store.currentTasks, planned.id).status, TaskStatus.planned);
|
||||
expect(store.currentTaskActivities, isEmpty);
|
||||
expect(store.currentProjectStatistics, isEmpty);
|
||||
expect(store.currentOperations, isEmpty);
|
||||
});
|
||||
|
||||
test('push commands and backlog move persist expected command outputs',
|
||||
() async {
|
||||
final first = task(
|
||||
id: 'first',
|
||||
title: 'First',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 9, 30),
|
||||
);
|
||||
final second = task(
|
||||
id: 'second',
|
||||
title: 'Second',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9, 30),
|
||||
end: DateTime.utc(2026, 6, 25, 10),
|
||||
);
|
||||
final tomorrowTask = task(
|
||||
id: 'tomorrow-existing',
|
||||
title: 'Tomorrow existing',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 26, 9),
|
||||
end: DateTime.utc(2026, 6, 26, 9, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [first, second, tomorrowTask]);
|
||||
final useCases = commands(store);
|
||||
|
||||
final pushToday = await useCases.pushFlexibleToNextAvailableSlot(
|
||||
context: appContext('push-today', DateTime.utc(2026, 6, 25, 8)),
|
||||
localDate: day,
|
||||
taskId: first.id,
|
||||
);
|
||||
final pushTomorrow = await useCases.pushFlexibleToTomorrowTopOfQueue(
|
||||
context: appContext('push-tomorrow', DateTime.utc(2026, 6, 25, 8, 5)),
|
||||
targetDate: tomorrow,
|
||||
taskId: second.id,
|
||||
);
|
||||
final moveBacklog = await useCases.moveFlexibleToBacklog(
|
||||
context: appContext('move-backlog', DateTime.utc(2026, 6, 25, 8, 10)),
|
||||
taskId: first.id,
|
||||
);
|
||||
|
||||
expect(pushToday.isSuccess, isTrue);
|
||||
expect(pushToday.requireValue.activities.first.code,
|
||||
TaskActivityCode.manuallyPushed);
|
||||
expect(pushTomorrow.isSuccess, isTrue);
|
||||
expect(
|
||||
taskById(store.currentTasks, second.id).scheduledStart,
|
||||
DateTime.utc(2026, 6, 26, 9),
|
||||
);
|
||||
expect(moveBacklog.isSuccess, isTrue);
|
||||
expect(taskById(store.currentTasks, first.id).status, TaskStatus.backlog);
|
||||
expect(
|
||||
store.currentTaskActivities
|
||||
.map((activity) => activity.operationId)
|
||||
.toSet(),
|
||||
containsAll({'push-today', 'push-tomorrow', 'move-backlog'}),
|
||||
);
|
||||
});
|
||||
|
||||
test('completion commands persist activities and project statistics',
|
||||
() async {
|
||||
final flexible = task(
|
||||
id: 'flexible',
|
||||
title: 'Flexible',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 9, 30),
|
||||
);
|
||||
final critical = task(
|
||||
id: 'critical',
|
||||
title: 'Critical',
|
||||
type: TaskType.critical,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 10),
|
||||
end: DateTime.utc(2026, 6, 25, 10, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [flexible, critical]);
|
||||
final useCases = commands(store);
|
||||
|
||||
final done = await useCases.completeFlexibleTask(
|
||||
context:
|
||||
appContext('complete-flexible', DateTime.utc(2026, 6, 25, 9, 20)),
|
||||
localDate: day,
|
||||
taskId: flexible.id,
|
||||
actualStart: DateTime.utc(2026, 6, 25, 9),
|
||||
actualEnd: DateTime.utc(2026, 6, 25, 9, 20),
|
||||
);
|
||||
final missed = await useCases.applyRequiredTaskAction(
|
||||
context: appContext('miss-critical', DateTime.utc(2026, 6, 25, 10, 45)),
|
||||
localDate: day,
|
||||
taskId: critical.id,
|
||||
action: RequiredTaskAction.missed,
|
||||
);
|
||||
|
||||
expect(done.isSuccess, isTrue);
|
||||
expect(taskById(store.currentTasks, flexible.id).status,
|
||||
TaskStatus.completed);
|
||||
expect(missed.isSuccess, isTrue);
|
||||
expect(
|
||||
taskById(store.currentTasks, critical.id).status, TaskStatus.backlog);
|
||||
expect(
|
||||
store.currentTaskActivities.map((activity) => activity.code).toSet(),
|
||||
{
|
||||
TaskActivityCode.completed,
|
||||
TaskActivityCode.missed,
|
||||
TaskActivityCode.movedToBacklog,
|
||||
},
|
||||
);
|
||||
expect(store.currentProjectStatistics.single.completedTaskCount, 1);
|
||||
});
|
||||
|
||||
test('surprise logging creates completed work and repairs flexible overlap',
|
||||
() async {
|
||||
final planned = task(
|
||||
id: 'planned',
|
||||
title: 'Planned',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 10),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [planned]);
|
||||
|
||||
final result = await commands(store).logSurpriseTask(
|
||||
context: appContext('surprise-op', DateTime.utc(2026, 6, 25, 9, 30)),
|
||||
localDate: day,
|
||||
title: 'Unexpected call',
|
||||
startedAt: DateTime.utc(2026, 6, 25, 9),
|
||||
timeUsedMinutes: 30,
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(taskById(store.currentTasks, 'surprise-op').status,
|
||||
TaskStatus.completed);
|
||||
expect(
|
||||
taskById(store.currentTasks, planned.id).scheduledStart,
|
||||
isNot(DateTime.utc(2026, 6, 25, 9)),
|
||||
);
|
||||
expect(
|
||||
store.currentTaskActivities.single.code, TaskActivityCode.completed);
|
||||
expect(store.currentProjectStatistics.single.completedTaskCount, 1);
|
||||
});
|
||||
|
||||
test('protected Free Slot create update and remove are atomic commands',
|
||||
() async {
|
||||
final store = storeWithSettings();
|
||||
final useCases = commands(store);
|
||||
final create = await useCases.createProtectedFreeSlot(
|
||||
context: appContext('free-create', DateTime.utc(2026, 6, 25, 8)),
|
||||
localDate: day,
|
||||
title: 'Rest',
|
||||
start: DateTime.utc(2026, 6, 25, 10),
|
||||
end: DateTime.utc(2026, 6, 25, 11),
|
||||
);
|
||||
final freeSlotId = create.requireValue.changedTasks.single.id;
|
||||
final update = await useCases.updateProtectedFreeSlot(
|
||||
context: appContext('free-update', DateTime.utc(2026, 6, 25, 8, 5)),
|
||||
localDate: day,
|
||||
taskId: freeSlotId,
|
||||
title: 'Quiet rest',
|
||||
start: DateTime.utc(2026, 6, 25, 11),
|
||||
end: DateTime.utc(2026, 6, 25, 12),
|
||||
);
|
||||
final remove = await useCases.removeProtectedFreeSlot(
|
||||
context: appContext('free-remove', DateTime.utc(2026, 6, 25, 8, 10)),
|
||||
localDate: day,
|
||||
taskId: freeSlotId,
|
||||
);
|
||||
|
||||
expect(create.isSuccess, isTrue);
|
||||
expect(update.isSuccess, isTrue);
|
||||
expect(taskById(store.currentTasks, freeSlotId).title, 'Quiet rest');
|
||||
expect(remove.isSuccess, isTrue);
|
||||
final removed = taskById(store.currentTasks, freeSlotId);
|
||||
expect(removed.status, TaskStatus.cancelled);
|
||||
expect(removed.scheduledStart, isNull);
|
||||
expect(store.currentOperations.map((operation) => operation.operationId),
|
||||
['free-create', 'free-update', 'free-remove']);
|
||||
});
|
||||
|
||||
test('child task commands create children and propagate completion',
|
||||
() async {
|
||||
final parent = task(
|
||||
id: 'parent',
|
||||
title: 'Parent',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 9),
|
||||
end: DateTime.utc(2026, 6, 25, 10),
|
||||
);
|
||||
final child = task(
|
||||
id: 'child',
|
||||
title: 'Child',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
parentTaskId: parent.id,
|
||||
);
|
||||
final sibling = task(
|
||||
id: 'sibling',
|
||||
title: 'Sibling',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
parentTaskId: parent.id,
|
||||
);
|
||||
final store = storeWithSettings(tasks: [parent, child, sibling]);
|
||||
final useCases = commands(store);
|
||||
|
||||
final breakUp = await useCases.breakUpTask(
|
||||
context: appContext('break-up', DateTime.utc(2026, 6, 25, 8)),
|
||||
parentTaskId: parent.id,
|
||||
children: const [
|
||||
ApplicationChildTaskDraft(title: 'New child'),
|
||||
],
|
||||
);
|
||||
final completeParent = await useCases.completeParentTask(
|
||||
context:
|
||||
appContext('complete-parent', DateTime.utc(2026, 6, 25, 8, 30)),
|
||||
parentTaskId: parent.id,
|
||||
);
|
||||
|
||||
expect(breakUp.isSuccess, isTrue);
|
||||
expect(breakUp.requireValue.childTaskIds, hasLength(1));
|
||||
expect(
|
||||
taskById(store.currentTasks, breakUp.requireValue.childTaskIds.single)
|
||||
.parentTaskId,
|
||||
parent.id,
|
||||
);
|
||||
expect(completeParent.isSuccess, isTrue);
|
||||
expect(
|
||||
taskById(store.currentTasks, parent.id).status, TaskStatus.completed);
|
||||
expect(
|
||||
taskById(store.currentTasks, child.id).status, TaskStatus.completed);
|
||||
expect(taskById(store.currentTasks, sibling.id).status,
|
||||
TaskStatus.completed);
|
||||
expect(store.currentProjectStatistics.single.completedTaskCount, 3);
|
||||
});
|
||||
|
||||
test('complete child and complete parent from child commands are exposed',
|
||||
() async {
|
||||
final parent = task(
|
||||
id: 'parent',
|
||||
title: 'Parent',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
final child = task(
|
||||
id: 'child',
|
||||
title: 'Child',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
parentTaskId: parent.id,
|
||||
);
|
||||
final store = storeWithSettings(tasks: [parent, child]);
|
||||
final useCases = commands(store);
|
||||
|
||||
final completeChild = await useCases.completeChildTask(
|
||||
context: appContext('complete-child', DateTime.utc(2026, 6, 25, 8)),
|
||||
childTaskId: child.id,
|
||||
);
|
||||
final secondStore = storeWithSettings(tasks: [parent, child]);
|
||||
final completeFromChild =
|
||||
await commands(secondStore).completeParentFromChild(
|
||||
context: appContext(
|
||||
'complete-parent-from-child', DateTime.utc(2026, 6, 25, 8)),
|
||||
childTaskId: child.id,
|
||||
);
|
||||
|
||||
expect(completeChild.isSuccess, isTrue);
|
||||
expect(
|
||||
taskById(store.currentTasks, parent.id).status, TaskStatus.completed);
|
||||
expect(completeFromChild.isSuccess, isTrue);
|
||||
expect(taskById(secondStore.currentTasks, parent.id).status,
|
||||
TaskStatus.completed);
|
||||
expect(taskById(secondStore.currentTasks, child.id).status,
|
||||
TaskStatus.completed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) {
|
||||
return V1ApplicationCommandUseCases(
|
||||
applicationStore: store,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
);
|
||||
}
|
||||
|
||||
InMemoryApplicationUnitOfWork storeWithSettings({
|
||||
List<Task> tasks = const <Task>[],
|
||||
}) {
|
||||
return InMemoryApplicationUnitOfWork(
|
||||
initialTasks: tasks,
|
||||
initialOwnerSettings: [
|
||||
OwnerSettings(
|
||||
ownerId: 'owner-1',
|
||||
timeZoneId: 'UTC',
|
||||
dayStart: WallTime(hour: 9, minute: 0),
|
||||
dayEnd: WallTime(hour: 17, minute: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
ApplicationOperationContext appContext(String operationId, DateTime now) {
|
||||
return ApplicationOperationContext.start(
|
||||
operationId: operationId,
|
||||
ownerTimeZone: OwnerTimeZoneContext(ownerId: 'owner-1', timeZoneId: 'UTC'),
|
||||
clock: FixedClock(now),
|
||||
idGenerator: SequentialIdGenerator(prefix: '$operationId-task'),
|
||||
);
|
||||
}
|
||||
|
||||
Task task({
|
||||
required String id,
|
||||
required String title,
|
||||
required TaskType type,
|
||||
required TaskStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
int? durationMinutes,
|
||||
String projectId = 'home',
|
||||
String? parentTaskId,
|
||||
}) {
|
||||
return Task(
|
||||
id: id,
|
||||
title: title,
|
||||
projectId: projectId,
|
||||
type: type,
|
||||
status: status,
|
||||
durationMinutes: durationMinutes ??
|
||||
(start != null && end != null ? end.difference(start).inMinutes : 30),
|
||||
scheduledStart: start,
|
||||
scheduledEnd: end,
|
||||
parentTaskId: parentTaskId,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
Task taskById(List<Task> tasks, String taskId) {
|
||||
return tasks.singleWhere((task) => task.id == taskId);
|
||||
}
|
||||
Loading…
Reference in a new issue