forked from eva/focus-flow
feat(app): add application unit of work foundation
This commit is contained in:
parent
edfb0cbba5
commit
2463fd7817
7 changed files with 1169 additions and 6 deletions
|
|
@ -43,8 +43,8 @@ passed. The current suite has 143 passing tests.
|
||||||
| 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 |
|
| 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, rollover notices | 14 |
|
| 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, rollover notices | 14 |
|
||||||
| Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 |
|
| Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 |
|
||||||
| Application layer | Domain services can be called directly | Coherent use cases that load state, invoke rules, persist all changes atomically, and return UI-ready results | 14 |
|
| Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, and in-memory atomic unit-of-work foundation | Concrete Today/read-model queries and V1 command use cases that invoke domain rules and return UI-ready results | 14 |
|
||||||
| Repository contracts | Basic task/project/locked/snapshot interfaces and in-memory fakes | Date/project/parent queries, archive/delete behavior, revisions, activity/settings repositories, unit of work, conformance suite | 14, 15 |
|
| 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 |
|
| 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 |
|
| MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 |
|
||||||
| Backend acceptance | Historical unit test audit and handoff | End-to-end application scenarios, adapter conformance, migration/resilience/performance checks, final backend gate | 17 |
|
| Backend acceptance | Historical unit test audit and handoff | End-to-end application scenarios, adapter conformance, migration/resilience/performance checks, final backend gate | 17 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# V1 Block 14 — Application Use Cases and UI-Ready Read Models
|
# V1 Block 14 — Application Use Cases and UI-Ready Read Models
|
||||||
|
|
||||||
Status: Planned
|
Status: In progress
|
||||||
|
|
||||||
Purpose: Add the orchestration layer that a future Flutter UI can call without
|
Purpose: Add the orchestration layer that a future Flutter UI can call without
|
||||||
assembling scheduler inputs, coordinating multiple repositories, or manually
|
assembling scheduler inputs, coordinating multiple repositories, or manually
|
||||||
|
|
@ -10,6 +10,8 @@ keeping tasks, activities, statistics, and notices consistent.
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Complete
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Introduce an application facade/use-case layer above the pure domain services.
|
- Introduce an application facade/use-case layer above the pure domain services.
|
||||||
|
|
@ -45,6 +47,31 @@ Acceptance criteria:
|
||||||
- Result codes are stable and do not depend on English text.
|
- Result codes are stable and do not depend on English text.
|
||||||
- The application layer has no Flutter or MongoDB imports.
|
- The application layer has no Flutter or MongoDB imports.
|
||||||
|
|
||||||
|
Completed implementation:
|
||||||
|
|
||||||
|
- Added `src/application_layer.dart` as a pure Dart application boundary above
|
||||||
|
the existing domain services and repository interfaces.
|
||||||
|
- Added operation context, owner time-zone context, typed result/failure
|
||||||
|
contracts, expected failure/persistence exception wrappers, owner settings,
|
||||||
|
operation records, and unit-of-work repository contracts.
|
||||||
|
- Added an in-memory staged unit-of-work implementation that commits task,
|
||||||
|
activity, project-statistics, settings, and operation-record changes
|
||||||
|
atomically, rejects duplicate operation IDs, and rolls back on typed,
|
||||||
|
validation, persistence, or unexpected failures.
|
||||||
|
- Added `ApplicationSchedulingLoader` so application commands can centralize
|
||||||
|
repository loading into `SchedulingInput` instead of letting UI callers
|
||||||
|
persist partial scheduler results.
|
||||||
|
- Added contract tests for successful commit, rollback on typed failure,
|
||||||
|
rollback on persistence failure, duplicate operation IDs, typed validation
|
||||||
|
mapping, and centralized scheduler input loading.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `dart format lib test`: passed, 0 files changed after final format
|
||||||
|
- `dart analyze`: passed, no issues found
|
||||||
|
- `dart test`: passed, 249 tests
|
||||||
|
- `git diff --check`: passed
|
||||||
|
|
||||||
BREAKPOINT: Stop here. Confirm `extra high` mode before building Today state and
|
BREAKPOINT: Stop here. Confirm `extra high` mode before building Today state and
|
||||||
command orchestration.
|
command orchestration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ The public library entry point is `lib/scheduler_core.dart`. It exports the
|
||||||
following source files:
|
following source files:
|
||||||
|
|
||||||
- `src/models.dart`
|
- `src/models.dart`
|
||||||
|
- `src/application_layer.dart`
|
||||||
- `src/backlog.dart`
|
- `src/backlog.dart`
|
||||||
- `src/child_tasks.dart`
|
- `src/child_tasks.dart`
|
||||||
- `src/document_mapping.dart`
|
- `src/document_mapping.dart`
|
||||||
|
|
@ -39,6 +40,7 @@ changes from accidental API drift.
|
||||||
|
|
||||||
| File | Enums |
|
| File | Enums |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `src/application_layer.dart` | `ApplicationFailureCode` |
|
||||||
| `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` |
|
| `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` |
|
||||||
| `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` |
|
| `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` |
|
||||||
| `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` |
|
| `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` |
|
||||||
|
|
@ -57,6 +59,7 @@ changes from accidental API drift.
|
||||||
|
|
||||||
| File | Public API |
|
| File | Public API |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `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/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
|
||||||
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
|
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
|
||||||
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
|
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
|
||||||
|
|
@ -256,6 +259,19 @@ Chunk 13.5 intentionally changed the public API:
|
||||||
- Added typed reminder directives for normal delivery, suppression, deferral,
|
- Added typed reminder directives for normal delivery, suppression, deferral,
|
||||||
and required-task acknowledgement, including protected Free Slot suppression.
|
and required-task acknowledgement, including protected Free Slot suppression.
|
||||||
|
|
||||||
|
Chunk 14.1 intentionally changed the public API:
|
||||||
|
|
||||||
|
- Added `src/application_layer.dart` and exported it from
|
||||||
|
`scheduler_core.dart`.
|
||||||
|
- Added application operation context, owner time-zone context, typed
|
||||||
|
result/failure contracts, and expected application/persistence exceptions.
|
||||||
|
- Added activity, project-statistics, owner-settings, operation-record, and
|
||||||
|
unit-of-work repository contracts for application orchestration.
|
||||||
|
- Added `OwnerSettings`, `ApplicationOperationRecord`,
|
||||||
|
`ApplicationSchedulingLoader`, and `InMemoryApplicationUnitOfWork`.
|
||||||
|
- Added in-memory staged transaction behavior with exactly-once operation
|
||||||
|
records and rollback on typed failures or persistence failures.
|
||||||
|
|
||||||
## Repository contracts
|
## Repository contracts
|
||||||
|
|
||||||
The current repository surface is pure Dart and in-memory only:
|
The current repository surface is pure Dart and in-memory only:
|
||||||
|
|
@ -266,6 +282,8 @@ The current repository surface is pure Dart and in-memory only:
|
||||||
`InMemoryLockedBlockRepository`.
|
`InMemoryLockedBlockRepository`.
|
||||||
- Snapshot repository behavior is represented by
|
- Snapshot repository behavior is represented by
|
||||||
`InMemorySchedulingSnapshotRepository`.
|
`InMemorySchedulingSnapshotRepository`.
|
||||||
|
- Application transaction behavior is represented by
|
||||||
|
`InMemoryApplicationUnitOfWork`.
|
||||||
|
|
||||||
Future MongoDB adapter work must remain behind repository interfaces and must
|
Future MongoDB adapter work must remain behind repository interfaces and must
|
||||||
not import MongoDB APIs into the scheduling core.
|
not import MongoDB APIs into the scheduling core.
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,13 @@ Latest Block 13.5 verification result on 2026-06-25:
|
||||||
- `dart test`: passed, 243 tests
|
- `dart test`: passed, 243 tests
|
||||||
- `git diff --check`: passed
|
- `git diff --check`: passed
|
||||||
|
|
||||||
|
Latest Block 14.1 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, 249 tests
|
||||||
|
- `git diff --check`: passed
|
||||||
|
|
||||||
Status values:
|
Status values:
|
||||||
|
|
||||||
- `complete`: Current backend APIs and tests cover the requirement.
|
- `complete`: Current backend APIs and tests cover the requirement.
|
||||||
|
|
@ -90,7 +97,7 @@ Status values:
|
||||||
| 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-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-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-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-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`, 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` | incomplete | Chunks 13.1-13.4 add canonical activities, exactly-once task/project statistic accounting, parent/child completion metadata, and learned suggestion aggregates; atomic persistence remains in Block 14. |
|
| 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. |
|
||||||
|
|
||||||
## Additional MVP backend requirements
|
## Additional MVP backend requirements
|
||||||
|
|
||||||
|
|
@ -102,9 +109,9 @@ Status values:
|
||||||
| 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-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. | `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-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-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, 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-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-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-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`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, 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` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; atomic persistence remains in Block 14. |
|
| 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-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. |
|
| 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
|
## Intentionally deferred human-spec items
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
// Keep this file small. Implementation details belong in `lib/src/`.
|
// Keep this file small. Implementation details belong in `lib/src/`.
|
||||||
|
|
||||||
export 'src/models.dart';
|
export 'src/models.dart';
|
||||||
|
export 'src/application_layer.dart';
|
||||||
export 'src/backlog.dart';
|
export 'src/backlog.dart';
|
||||||
export 'src/child_tasks.dart';
|
export 'src/child_tasks.dart';
|
||||||
export 'src/document_mapping.dart';
|
export 'src/document_mapping.dart';
|
||||||
|
|
|
||||||
817
lib/src/application_layer.dart
Normal file
817
lib/src/application_layer.dart
Normal file
|
|
@ -0,0 +1,817 @@
|
||||||
|
// Application orchestration contracts for UI-facing use cases.
|
||||||
|
//
|
||||||
|
// This layer sits above the pure domain services and repository interfaces. It
|
||||||
|
// gives future Flutter/use-case code one atomic boundary per user command
|
||||||
|
// without importing widgets, MongoDB clients, or platform notification APIs.
|
||||||
|
|
||||||
|
import 'backlog.dart';
|
||||||
|
import 'locked_time.dart';
|
||||||
|
import 'models.dart';
|
||||||
|
import 'project_statistics.dart';
|
||||||
|
import 'repositories.dart';
|
||||||
|
import 'scheduling_engine.dart';
|
||||||
|
import 'task_lifecycle.dart';
|
||||||
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
|
/// Owner-local time context supplied to application operations.
|
||||||
|
class OwnerTimeZoneContext {
|
||||||
|
OwnerTimeZoneContext({
|
||||||
|
required String ownerId,
|
||||||
|
required String timeZoneId,
|
||||||
|
}) : ownerId = _requiredTrimmed(ownerId, 'ownerId'),
|
||||||
|
timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId');
|
||||||
|
|
||||||
|
/// Authorization-neutral owner scope. V1 does not implement accounts.
|
||||||
|
final String ownerId;
|
||||||
|
|
||||||
|
/// IANA-style time-zone identifier chosen by the app boundary.
|
||||||
|
final String timeZoneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-operation application context.
|
||||||
|
class ApplicationOperationContext {
|
||||||
|
ApplicationOperationContext({
|
||||||
|
required String operationId,
|
||||||
|
required this.now,
|
||||||
|
required this.ownerTimeZone,
|
||||||
|
required this.clock,
|
||||||
|
required this.idGenerator,
|
||||||
|
}) : operationId = _requiredTrimmed(operationId, 'operationId');
|
||||||
|
|
||||||
|
/// Convenience constructor that captures [Clock.now] once.
|
||||||
|
factory ApplicationOperationContext.start({
|
||||||
|
required String operationId,
|
||||||
|
required OwnerTimeZoneContext ownerTimeZone,
|
||||||
|
required Clock clock,
|
||||||
|
required IdGenerator idGenerator,
|
||||||
|
}) {
|
||||||
|
return ApplicationOperationContext(
|
||||||
|
operationId: operationId,
|
||||||
|
now: clock.now(),
|
||||||
|
ownerTimeZone: ownerTimeZone,
|
||||||
|
clock: clock,
|
||||||
|
idGenerator: idGenerator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exactly-once key for this user command.
|
||||||
|
final String operationId;
|
||||||
|
|
||||||
|
/// Instant used by the operation. Use this instead of repeatedly reading the
|
||||||
|
/// clock during one command.
|
||||||
|
final DateTime now;
|
||||||
|
|
||||||
|
/// Owner scope and time-zone data for date-sensitive rules.
|
||||||
|
final OwnerTimeZoneContext ownerTimeZone;
|
||||||
|
|
||||||
|
/// Injected clock available to lower-level services that need one.
|
||||||
|
final Clock clock;
|
||||||
|
|
||||||
|
/// Injected ID generator for records created by the operation.
|
||||||
|
final IdGenerator idGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable application-layer result categories.
|
||||||
|
enum ApplicationFailureCode {
|
||||||
|
validation,
|
||||||
|
notFound,
|
||||||
|
conflict,
|
||||||
|
staleRevision,
|
||||||
|
noSlot,
|
||||||
|
duplicateOperation,
|
||||||
|
persistenceFailure,
|
||||||
|
unexpected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed failure returned by application commands and queries.
|
||||||
|
class ApplicationFailure {
|
||||||
|
const ApplicationFailure({
|
||||||
|
required this.code,
|
||||||
|
this.entityId,
|
||||||
|
this.detailCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stable machine-readable category.
|
||||||
|
final ApplicationFailureCode code;
|
||||||
|
|
||||||
|
/// Optional stable entity id related to the failure.
|
||||||
|
final String? entityId;
|
||||||
|
|
||||||
|
/// Optional narrower stable reason, such as a domain validation code name.
|
||||||
|
final String? detailCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result wrapper for expected application success/failure paths.
|
||||||
|
class ApplicationResult<T> {
|
||||||
|
const ApplicationResult._({
|
||||||
|
this.value,
|
||||||
|
this.failure,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Successful result carrying [value].
|
||||||
|
factory ApplicationResult.success(T value) {
|
||||||
|
return ApplicationResult._(value: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected failure result.
|
||||||
|
factory ApplicationResult.failure(ApplicationFailure failure) {
|
||||||
|
return ApplicationResult._(failure: failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful value, if any.
|
||||||
|
final T? value;
|
||||||
|
|
||||||
|
/// Typed failure, if the operation did not succeed.
|
||||||
|
final ApplicationFailure? failure;
|
||||||
|
|
||||||
|
/// Whether the operation succeeded.
|
||||||
|
bool get isSuccess => failure == null;
|
||||||
|
|
||||||
|
/// Whether the operation failed with a typed application failure.
|
||||||
|
bool get isFailure => failure != null;
|
||||||
|
|
||||||
|
/// Return the value or throw if this result is a failure.
|
||||||
|
T get requireValue {
|
||||||
|
if (failure != null) {
|
||||||
|
throw StateError(
|
||||||
|
'Application result is a failure: ${failure!.code.name}');
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception for expected failures raised inside a unit-of-work callback.
|
||||||
|
class ApplicationFailureException implements Exception {
|
||||||
|
const ApplicationFailureException(this.failure);
|
||||||
|
|
||||||
|
/// Failure to return from the application boundary.
|
||||||
|
final ApplicationFailure failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception representing persistence/driver failures at repository boundaries.
|
||||||
|
class ApplicationPersistenceException implements Exception {
|
||||||
|
const ApplicationPersistenceException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted exactly-once operation record.
|
||||||
|
class ApplicationOperationRecord {
|
||||||
|
ApplicationOperationRecord({
|
||||||
|
required String operationId,
|
||||||
|
required String ownerId,
|
||||||
|
required String operationName,
|
||||||
|
required this.committedAt,
|
||||||
|
}) : operationId = _requiredTrimmed(operationId, 'operationId'),
|
||||||
|
ownerId = _requiredTrimmed(ownerId, 'ownerId'),
|
||||||
|
operationName = _requiredTrimmed(operationName, 'operationName');
|
||||||
|
|
||||||
|
/// Exactly-once operation id.
|
||||||
|
final String operationId;
|
||||||
|
|
||||||
|
/// Authorization-neutral owner scope.
|
||||||
|
final String ownerId;
|
||||||
|
|
||||||
|
/// Stable use-case/command label.
|
||||||
|
final String operationName;
|
||||||
|
|
||||||
|
/// Commit instant.
|
||||||
|
final DateTime committedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner-level settings needed by application use cases.
|
||||||
|
class OwnerSettings {
|
||||||
|
OwnerSettings({
|
||||||
|
required String ownerId,
|
||||||
|
required String timeZoneId,
|
||||||
|
WallTime? dayStart,
|
||||||
|
WallTime? dayEnd,
|
||||||
|
this.compactModeEnabled = false,
|
||||||
|
this.backlogStalenessSettings = const BacklogStalenessSettings(),
|
||||||
|
}) : ownerId = _requiredTrimmed(ownerId, 'ownerId'),
|
||||||
|
timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'),
|
||||||
|
dayStart = dayStart ?? WallTime(hour: 0, minute: 0),
|
||||||
|
dayEnd = dayEnd ?? WallTime(hour: 23, minute: 59) {
|
||||||
|
if (this.dayStart.compareTo(this.dayEnd) >= 0) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
{'dayStart': this.dayStart, 'dayEnd': this.dayEnd},
|
||||||
|
'dayEnd',
|
||||||
|
'Day end must be after day start.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authorization-neutral owner scope.
|
||||||
|
final String ownerId;
|
||||||
|
|
||||||
|
/// Owner time-zone id used by date-sensitive application use cases.
|
||||||
|
final String timeZoneId;
|
||||||
|
|
||||||
|
/// Default local day start.
|
||||||
|
final WallTime dayStart;
|
||||||
|
|
||||||
|
/// Default local day end.
|
||||||
|
final WallTime dayEnd;
|
||||||
|
|
||||||
|
/// Persisted compact-mode preference.
|
||||||
|
final bool compactModeEnabled;
|
||||||
|
|
||||||
|
/// Configurable backlog age thresholds.
|
||||||
|
final BacklogStalenessSettings backlogStalenessSettings;
|
||||||
|
|
||||||
|
/// Return a copy with selected settings changed.
|
||||||
|
OwnerSettings copyWith({
|
||||||
|
String? ownerId,
|
||||||
|
String? timeZoneId,
|
||||||
|
WallTime? dayStart,
|
||||||
|
WallTime? dayEnd,
|
||||||
|
bool? compactModeEnabled,
|
||||||
|
BacklogStalenessSettings? backlogStalenessSettings,
|
||||||
|
}) {
|
||||||
|
return OwnerSettings(
|
||||||
|
ownerId: ownerId ?? this.ownerId,
|
||||||
|
timeZoneId: timeZoneId ?? this.timeZoneId,
|
||||||
|
dayStart: dayStart ?? this.dayStart,
|
||||||
|
dayEnd: dayEnd ?? this.dayEnd,
|
||||||
|
compactModeEnabled: compactModeEnabled ?? this.compactModeEnabled,
|
||||||
|
backlogStalenessSettings:
|
||||||
|
backlogStalenessSettings ?? this.backlogStalenessSettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository contract for internal task activities.
|
||||||
|
abstract interface class TaskActivityRepository {
|
||||||
|
Future<TaskActivity?> findById(String id);
|
||||||
|
|
||||||
|
Future<List<TaskActivity>> findByOperationId(String operationId);
|
||||||
|
|
||||||
|
Future<List<TaskActivity>> findByTaskId(String taskId);
|
||||||
|
|
||||||
|
Future<List<TaskActivity>> findAll();
|
||||||
|
|
||||||
|
Future<void> save(TaskActivity activity);
|
||||||
|
|
||||||
|
Future<void> saveAll(Iterable<TaskActivity> activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository contract for project statistics aggregates.
|
||||||
|
abstract interface class ProjectStatisticsRepository {
|
||||||
|
Future<ProjectStatistics?> findByProjectId(String projectId);
|
||||||
|
|
||||||
|
Future<List<ProjectStatistics>> findAll();
|
||||||
|
|
||||||
|
Future<void> save(ProjectStatistics statistics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository contract for owner settings.
|
||||||
|
abstract interface class OwnerSettingsRepository {
|
||||||
|
Future<OwnerSettings?> findByOwnerId(String ownerId);
|
||||||
|
|
||||||
|
Future<void> save(OwnerSettings settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository contract for exactly-once operation records.
|
||||||
|
abstract interface class ApplicationOperationRepository {
|
||||||
|
Future<ApplicationOperationRecord?> findById(String operationId);
|
||||||
|
|
||||||
|
Future<void> save(ApplicationOperationRecord operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository set available inside one application unit of work.
|
||||||
|
abstract interface class ApplicationUnitOfWorkRepositories {
|
||||||
|
TaskRepository get tasks;
|
||||||
|
|
||||||
|
ProjectRepository get projects;
|
||||||
|
|
||||||
|
LockedBlockRepository get lockedBlocks;
|
||||||
|
|
||||||
|
SchedulingSnapshotRepository get schedulingSnapshots;
|
||||||
|
|
||||||
|
TaskActivityRepository get taskActivities;
|
||||||
|
|
||||||
|
ProjectStatisticsRepository get projectStatistics;
|
||||||
|
|
||||||
|
OwnerSettingsRepository get ownerSettings;
|
||||||
|
|
||||||
|
ApplicationOperationRepository get operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic application transaction boundary.
|
||||||
|
abstract interface class ApplicationUnitOfWork {
|
||||||
|
Future<ApplicationResult<T>> run<T>({
|
||||||
|
required ApplicationOperationContext context,
|
||||||
|
required String operationName,
|
||||||
|
required Future<ApplicationResult<T>> Function(
|
||||||
|
ApplicationUnitOfWorkRepositories repositories,
|
||||||
|
) action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository loader for common scheduler input assembly.
|
||||||
|
class ApplicationSchedulingLoader {
|
||||||
|
const ApplicationSchedulingLoader(this.repositories);
|
||||||
|
|
||||||
|
/// Repositories for the current application operation.
|
||||||
|
final ApplicationUnitOfWorkRepositories repositories;
|
||||||
|
|
||||||
|
/// Load scheduler input for [window] from the repository boundary.
|
||||||
|
///
|
||||||
|
/// UI code should not persist partial [SchedulingResult] objects. Commands
|
||||||
|
/// should load authoritative state here, invoke domain services, then commit
|
||||||
|
/// resulting entities through a unit of work.
|
||||||
|
Future<SchedulingInput> loadInputForWindow({
|
||||||
|
required SchedulingWindow window,
|
||||||
|
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
||||||
|
List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
|
||||||
|
}) async {
|
||||||
|
final scheduledTasks = await repositories.tasks.findScheduledInWindow(
|
||||||
|
window,
|
||||||
|
);
|
||||||
|
return SchedulingInput(
|
||||||
|
tasks: scheduledTasks,
|
||||||
|
window: window,
|
||||||
|
lockedIntervals: lockedIntervals,
|
||||||
|
requiredVisibleIntervals: requiredVisibleIntervals,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory unit-of-work implementation for deterministic application tests.
|
||||||
|
class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
||||||
|
InMemoryApplicationUnitOfWork({
|
||||||
|
Iterable<Task> initialTasks = const <Task>[],
|
||||||
|
Iterable<ProjectProfile> initialProjects = const <ProjectProfile>[],
|
||||||
|
Iterable<LockedBlock> initialLockedBlocks = const <LockedBlock>[],
|
||||||
|
Iterable<LockedBlockOverride> initialLockedOverrides =
|
||||||
|
const <LockedBlockOverride>[],
|
||||||
|
Iterable<SchedulingStateSnapshot> initialSchedulingSnapshots =
|
||||||
|
const <SchedulingStateSnapshot>[],
|
||||||
|
Iterable<TaskActivity> initialTaskActivities = const <TaskActivity>[],
|
||||||
|
Iterable<ProjectStatistics> initialProjectStatistics =
|
||||||
|
const <ProjectStatistics>[],
|
||||||
|
Iterable<OwnerSettings> initialOwnerSettings = const <OwnerSettings>[],
|
||||||
|
Iterable<ApplicationOperationRecord> initialOperations =
|
||||||
|
const <ApplicationOperationRecord>[],
|
||||||
|
}) : _state = _InMemoryApplicationState(
|
||||||
|
tasksById: {
|
||||||
|
for (final task in initialTasks) task.id: task,
|
||||||
|
},
|
||||||
|
projectsById: {
|
||||||
|
for (final project in initialProjects) project.id: project,
|
||||||
|
},
|
||||||
|
lockedBlocksById: {
|
||||||
|
for (final block in initialLockedBlocks) block.id: block,
|
||||||
|
},
|
||||||
|
lockedOverridesById: {
|
||||||
|
for (final override in initialLockedOverrides)
|
||||||
|
override.id: override,
|
||||||
|
},
|
||||||
|
schedulingSnapshotsById: {
|
||||||
|
for (final snapshot in initialSchedulingSnapshots)
|
||||||
|
snapshot.id: snapshot,
|
||||||
|
},
|
||||||
|
taskActivitiesById: {
|
||||||
|
for (final activity in initialTaskActivities) activity.id: activity,
|
||||||
|
},
|
||||||
|
projectStatisticsByProjectId: {
|
||||||
|
for (final statistics in initialProjectStatistics)
|
||||||
|
statistics.projectId: statistics,
|
||||||
|
},
|
||||||
|
ownerSettingsByOwnerId: {
|
||||||
|
for (final settings in initialOwnerSettings)
|
||||||
|
settings.ownerId: settings,
|
||||||
|
},
|
||||||
|
operationsById: {
|
||||||
|
for (final operation in initialOperations)
|
||||||
|
operation.operationId: operation,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_InMemoryApplicationState _state;
|
||||||
|
bool _failNextCommit = false;
|
||||||
|
|
||||||
|
/// Current committed tasks, useful for contract tests.
|
||||||
|
List<Task> get currentTasks =>
|
||||||
|
List<Task>.unmodifiable(_state.tasksById.values);
|
||||||
|
|
||||||
|
/// Current committed activities, useful for contract tests.
|
||||||
|
List<TaskActivity> get currentTaskActivities {
|
||||||
|
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current committed project statistics, useful for contract tests.
|
||||||
|
List<ProjectStatistics> get currentProjectStatistics {
|
||||||
|
return List<ProjectStatistics>.unmodifiable(
|
||||||
|
_state.projectStatisticsByProjectId.values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current committed owner settings, useful for contract tests.
|
||||||
|
List<OwnerSettings> get currentOwnerSettings {
|
||||||
|
return List<OwnerSettings>.unmodifiable(
|
||||||
|
_state.ownerSettingsByOwnerId.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current committed operation records, useful for contract tests.
|
||||||
|
List<ApplicationOperationRecord> get currentOperations {
|
||||||
|
return List<ApplicationOperationRecord>.unmodifiable(
|
||||||
|
_state.operationsById.values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cause the next successful transaction to fail at commit time.
|
||||||
|
void failNextCommitForTesting() {
|
||||||
|
_failNextCommit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApplicationResult<T>> run<T>({
|
||||||
|
required ApplicationOperationContext context,
|
||||||
|
required String operationName,
|
||||||
|
required Future<ApplicationResult<T>> Function(
|
||||||
|
ApplicationUnitOfWorkRepositories repositories,
|
||||||
|
) action,
|
||||||
|
}) async {
|
||||||
|
if (_state.operationsById.containsKey(context.operationId)) {
|
||||||
|
return ApplicationResult.failure(
|
||||||
|
ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.duplicateOperation,
|
||||||
|
entityId: context.operationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final staged = _state.copy();
|
||||||
|
final repositories = _InMemoryApplicationRepositories(staged);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await action(repositories);
|
||||||
|
if (result.isFailure) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
staged.operationsById[context.operationId] = ApplicationOperationRecord(
|
||||||
|
operationId: context.operationId,
|
||||||
|
ownerId: context.ownerTimeZone.ownerId,
|
||||||
|
operationName: operationName,
|
||||||
|
committedAt: context.now,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_failNextCommit) {
|
||||||
|
_failNextCommit = false;
|
||||||
|
throw const ApplicationPersistenceException();
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = staged;
|
||||||
|
return result;
|
||||||
|
} on ApplicationFailureException catch (error) {
|
||||||
|
return ApplicationResult.failure(error.failure);
|
||||||
|
} on DomainValidationException catch (error) {
|
||||||
|
return ApplicationResult.failure(
|
||||||
|
ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.validation,
|
||||||
|
detailCode: error.code.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on ApplicationPersistenceException {
|
||||||
|
return ApplicationResult.failure(
|
||||||
|
const ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.persistenceFailure,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return ApplicationResult.failure(
|
||||||
|
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InMemoryApplicationState {
|
||||||
|
_InMemoryApplicationState({
|
||||||
|
required this.tasksById,
|
||||||
|
required this.projectsById,
|
||||||
|
required this.lockedBlocksById,
|
||||||
|
required this.lockedOverridesById,
|
||||||
|
required this.schedulingSnapshotsById,
|
||||||
|
required this.taskActivitiesById,
|
||||||
|
required this.projectStatisticsByProjectId,
|
||||||
|
required this.ownerSettingsByOwnerId,
|
||||||
|
required this.operationsById,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, Task> tasksById;
|
||||||
|
final Map<String, ProjectProfile> projectsById;
|
||||||
|
final Map<String, LockedBlock> lockedBlocksById;
|
||||||
|
final Map<String, LockedBlockOverride> lockedOverridesById;
|
||||||
|
final Map<String, SchedulingStateSnapshot> schedulingSnapshotsById;
|
||||||
|
final Map<String, TaskActivity> taskActivitiesById;
|
||||||
|
final Map<String, ProjectStatistics> projectStatisticsByProjectId;
|
||||||
|
final Map<String, OwnerSettings> ownerSettingsByOwnerId;
|
||||||
|
final Map<String, ApplicationOperationRecord> operationsById;
|
||||||
|
|
||||||
|
_InMemoryApplicationState copy() {
|
||||||
|
return _InMemoryApplicationState(
|
||||||
|
tasksById: Map<String, Task>.of(tasksById),
|
||||||
|
projectsById: Map<String, ProjectProfile>.of(projectsById),
|
||||||
|
lockedBlocksById: Map<String, LockedBlock>.of(lockedBlocksById),
|
||||||
|
lockedOverridesById:
|
||||||
|
Map<String, LockedBlockOverride>.of(lockedOverridesById),
|
||||||
|
schedulingSnapshotsById:
|
||||||
|
Map<String, SchedulingStateSnapshot>.of(schedulingSnapshotsById),
|
||||||
|
taskActivitiesById: Map<String, TaskActivity>.of(taskActivitiesById),
|
||||||
|
projectStatisticsByProjectId:
|
||||||
|
Map<String, ProjectStatistics>.of(projectStatisticsByProjectId),
|
||||||
|
ownerSettingsByOwnerId: Map<String, OwnerSettings>.of(
|
||||||
|
ownerSettingsByOwnerId,
|
||||||
|
),
|
||||||
|
operationsById: Map<String, ApplicationOperationRecord>.of(
|
||||||
|
operationsById,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InMemoryApplicationRepositories
|
||||||
|
implements ApplicationUnitOfWorkRepositories {
|
||||||
|
_InMemoryApplicationRepositories(_InMemoryApplicationState state)
|
||||||
|
: tasks = _UnitOfWorkTaskRepository(state.tasksById),
|
||||||
|
projects = _UnitOfWorkProjectRepository(state.projectsById),
|
||||||
|
lockedBlocks = _UnitOfWorkLockedBlockRepository(
|
||||||
|
blocksById: state.lockedBlocksById,
|
||||||
|
overridesById: state.lockedOverridesById,
|
||||||
|
),
|
||||||
|
schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository(
|
||||||
|
state.schedulingSnapshotsById,
|
||||||
|
),
|
||||||
|
taskActivities = _UnitOfWorkTaskActivityRepository(
|
||||||
|
state.taskActivitiesById,
|
||||||
|
),
|
||||||
|
projectStatistics = _UnitOfWorkProjectStatisticsRepository(
|
||||||
|
state.projectStatisticsByProjectId,
|
||||||
|
),
|
||||||
|
ownerSettings = _UnitOfWorkOwnerSettingsRepository(
|
||||||
|
state.ownerSettingsByOwnerId,
|
||||||
|
),
|
||||||
|
operations = _UnitOfWorkOperationRepository(state.operationsById);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final TaskRepository tasks;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ProjectRepository projects;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final LockedBlockRepository lockedBlocks;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final SchedulingSnapshotRepository schedulingSnapshots;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final TaskActivityRepository taskActivities;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ProjectStatisticsRepository projectStatistics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final OwnerSettingsRepository ownerSettings;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ApplicationOperationRepository operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkTaskRepository implements TaskRepository {
|
||||||
|
_UnitOfWorkTaskRepository(this._tasksById);
|
||||||
|
|
||||||
|
final Map<String, Task> _tasksById;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Task?> findById(String id) async => _tasksById[id];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Task>> findAll() async {
|
||||||
|
return List<Task>.unmodifiable(_tasksById.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Task>> findByStatus(TaskStatus status) async {
|
||||||
|
return List<Task>.unmodifiable(
|
||||||
|
_tasksById.values.where((task) => task.status == status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Task>> findScheduledInWindow(SchedulingWindow window) async {
|
||||||
|
return List<Task>.unmodifiable(
|
||||||
|
_tasksById.values.where((task) {
|
||||||
|
final start = task.scheduledStart;
|
||||||
|
final end = task.scheduledEnd;
|
||||||
|
if (start == null || end == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return TimeInterval(start: start, end: end).overlaps(window.interval);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(Task task) async {
|
||||||
|
_tasksById[task.id] = task;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveAll(Iterable<Task> tasks) async {
|
||||||
|
for (final task in tasks) {
|
||||||
|
_tasksById[task.id] = task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkProjectRepository implements ProjectRepository {
|
||||||
|
_UnitOfWorkProjectRepository(this._projectsById);
|
||||||
|
|
||||||
|
final Map<String, ProjectProfile> _projectsById;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProjectProfile?> findById(String id) async => _projectsById[id];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProjectProfile>> findAll() async {
|
||||||
|
return List<ProjectProfile>.unmodifiable(_projectsById.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(ProjectProfile project) async {
|
||||||
|
_projectsById[project.id] = project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository {
|
||||||
|
_UnitOfWorkLockedBlockRepository({
|
||||||
|
required Map<String, LockedBlock> blocksById,
|
||||||
|
required Map<String, LockedBlockOverride> overridesById,
|
||||||
|
}) : _blocksById = blocksById,
|
||||||
|
_overridesById = overridesById;
|
||||||
|
|
||||||
|
final Map<String, LockedBlock> _blocksById;
|
||||||
|
final Map<String, LockedBlockOverride> _overridesById;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LockedBlock?> findBlockById(String id) async => _blocksById[id];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<LockedBlock>> findAllBlocks() async {
|
||||||
|
return List<LockedBlock>.unmodifiable(_blocksById.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LockedBlockOverride?> findOverrideById(String id) async {
|
||||||
|
return _overridesById[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<LockedBlockOverride>> findAllOverrides() async {
|
||||||
|
return List<LockedBlockOverride>.unmodifiable(_overridesById.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveBlock(LockedBlock block) async {
|
||||||
|
_blocksById[block.id] = block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveOverride(LockedBlockOverride override) async {
|
||||||
|
_overridesById[override.id] = override;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkSchedulingSnapshotRepository
|
||||||
|
implements SchedulingSnapshotRepository {
|
||||||
|
_UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById);
|
||||||
|
|
||||||
|
final Map<String, SchedulingStateSnapshot> _snapshotsById;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SchedulingStateSnapshot?> findById(String id) async {
|
||||||
|
return _snapshotsById[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SchedulingStateSnapshot>> findInWindow(
|
||||||
|
SchedulingWindow window,
|
||||||
|
) async {
|
||||||
|
return List<SchedulingStateSnapshot>.unmodifiable(
|
||||||
|
_snapshotsById.values.where(
|
||||||
|
(snapshot) => snapshot.window.interval.overlaps(window.interval),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(SchedulingStateSnapshot snapshot) async {
|
||||||
|
_snapshotsById[snapshot.id] = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository {
|
||||||
|
_UnitOfWorkTaskActivityRepository(this._activitiesById);
|
||||||
|
|
||||||
|
final Map<String, TaskActivity> _activitiesById;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<TaskActivity?> findById(String id) async => _activitiesById[id];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<TaskActivity>> findByOperationId(String operationId) async {
|
||||||
|
return List<TaskActivity>.unmodifiable(
|
||||||
|
_activitiesById.values.where(
|
||||||
|
(activity) => activity.operationId == operationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<TaskActivity>> findByTaskId(String taskId) async {
|
||||||
|
return List<TaskActivity>.unmodifiable(
|
||||||
|
_activitiesById.values.where((activity) => activity.taskId == taskId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<TaskActivity>> findAll() async {
|
||||||
|
return List<TaskActivity>.unmodifiable(_activitiesById.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(TaskActivity activity) async {
|
||||||
|
_activitiesById[activity.id] = activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveAll(Iterable<TaskActivity> activities) async {
|
||||||
|
for (final activity in activities) {
|
||||||
|
_activitiesById[activity.id] = activity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkProjectStatisticsRepository
|
||||||
|
implements ProjectStatisticsRepository {
|
||||||
|
_UnitOfWorkProjectStatisticsRepository(this._statisticsByProjectId);
|
||||||
|
|
||||||
|
final Map<String, ProjectStatistics> _statisticsByProjectId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProjectStatistics?> findByProjectId(String projectId) async {
|
||||||
|
return _statisticsByProjectId[projectId];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProjectStatistics>> findAll() async {
|
||||||
|
return List<ProjectStatistics>.unmodifiable(_statisticsByProjectId.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(ProjectStatistics statistics) async {
|
||||||
|
_statisticsByProjectId[statistics.projectId] = statistics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository {
|
||||||
|
_UnitOfWorkOwnerSettingsRepository(this._settingsByOwnerId);
|
||||||
|
|
||||||
|
final Map<String, OwnerSettings> _settingsByOwnerId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OwnerSettings?> findByOwnerId(String ownerId) async {
|
||||||
|
return _settingsByOwnerId[ownerId];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(OwnerSettings settings) async {
|
||||||
|
_settingsByOwnerId[settings.ownerId] = settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnitOfWorkOperationRepository implements ApplicationOperationRepository {
|
||||||
|
_UnitOfWorkOperationRepository(this._operationsById);
|
||||||
|
|
||||||
|
final Map<String, ApplicationOperationRecord> _operationsById;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApplicationOperationRecord?> findById(String operationId) async {
|
||||||
|
return _operationsById[operationId];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(ApplicationOperationRecord operation) async {
|
||||||
|
_operationsById[operation.operationId] = operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _requiredTrimmed(String value, String name) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
throw ArgumentError.value(value, name, 'Value is required.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
293
test/application_layer_test.dart
Normal file
293
test/application_layer_test.dart
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Application unit of work', () {
|
||||||
|
final now = DateTime.utc(2026, 6, 25, 16);
|
||||||
|
final context = appContext(operationId: 'op-1', now: now);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'commits task, activity, aggregate, settings, and operation atomically',
|
||||||
|
() async {
|
||||||
|
final original = task(
|
||||||
|
id: 'task-1',
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
||||||
|
initialTasks: [original],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await unitOfWork.run<String>(
|
||||||
|
context: context,
|
||||||
|
operationName: 'complete-task',
|
||||||
|
action: (repositories) async {
|
||||||
|
final loaded = await repositories.tasks.findById(original.id);
|
||||||
|
final completed = loaded!.copyWith(
|
||||||
|
status: TaskStatus.completed,
|
||||||
|
completedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final activity = activityFor(
|
||||||
|
id: 'activity-1',
|
||||||
|
operationId: context.operationId,
|
||||||
|
task: completed,
|
||||||
|
occurredAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repositories.tasks.save(completed);
|
||||||
|
await repositories.taskActivities.save(activity);
|
||||||
|
await repositories.projectStatistics.save(
|
||||||
|
ProjectStatistics(projectId: completed.projectId)
|
||||||
|
.recordCompletion(completedAt: now, durationMinutes: 30),
|
||||||
|
);
|
||||||
|
await repositories.ownerSettings.save(
|
||||||
|
OwnerSettings(
|
||||||
|
ownerId: 'owner-1', timeZoneId: 'America/Los_Angeles'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApplicationResult.success(completed.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isSuccess, isTrue);
|
||||||
|
expect(result.requireValue, 'task-1');
|
||||||
|
expect(unitOfWork.currentTasks.single.status, TaskStatus.completed);
|
||||||
|
expect(unitOfWork.currentTaskActivities.single.id, 'activity-1');
|
||||||
|
expect(
|
||||||
|
unitOfWork.currentProjectStatistics.single.completedTaskCount,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(unitOfWork.currentOwnerSettings.single.ownerId, 'owner-1');
|
||||||
|
expect(unitOfWork.currentOperations.single.operationId, 'op-1');
|
||||||
|
expect(
|
||||||
|
unitOfWork.currentOperations.single.operationName, 'complete-task');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolls back staged changes when the action returns a typed failure',
|
||||||
|
() async {
|
||||||
|
final original = task(
|
||||||
|
id: 'task-1',
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
||||||
|
initialTasks: [original],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await unitOfWork.run<void>(
|
||||||
|
context: context,
|
||||||
|
operationName: 'complete-task',
|
||||||
|
action: (repositories) async {
|
||||||
|
await repositories.tasks.save(
|
||||||
|
original.copyWith(status: TaskStatus.completed, updatedAt: now),
|
||||||
|
);
|
||||||
|
await repositories.taskActivities.save(
|
||||||
|
activityFor(
|
||||||
|
id: 'activity-1',
|
||||||
|
operationId: context.operationId,
|
||||||
|
task: original,
|
||||||
|
occurredAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApplicationResult.failure(
|
||||||
|
const ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.conflict,
|
||||||
|
entityId: 'task-1',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.failure!.code, ApplicationFailureCode.conflict);
|
||||||
|
expect(unitOfWork.currentTasks.single.status, TaskStatus.planned);
|
||||||
|
expect(unitOfWork.currentTaskActivities, isEmpty);
|
||||||
|
expect(unitOfWork.currentOperations, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolls back staged changes when commit persistence fails', () async {
|
||||||
|
final original = task(
|
||||||
|
id: 'task-1',
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
||||||
|
initialTasks: [original],
|
||||||
|
)..failNextCommitForTesting();
|
||||||
|
|
||||||
|
final result = await unitOfWork.run<void>(
|
||||||
|
context: context,
|
||||||
|
operationName: 'complete-task',
|
||||||
|
action: (repositories) async {
|
||||||
|
await repositories.tasks.save(
|
||||||
|
original.copyWith(status: TaskStatus.completed, updatedAt: now),
|
||||||
|
);
|
||||||
|
return ApplicationResult.success(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.failure!.code, ApplicationFailureCode.persistenceFailure);
|
||||||
|
expect(unitOfWork.currentTasks.single.status, TaskStatus.planned);
|
||||||
|
expect(unitOfWork.currentOperations, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects duplicate operation IDs without running the action',
|
||||||
|
() async {
|
||||||
|
final unitOfWork = InMemoryApplicationUnitOfWork();
|
||||||
|
var runCount = 0;
|
||||||
|
|
||||||
|
final first = await unitOfWork.run<String>(
|
||||||
|
context: context,
|
||||||
|
operationName: 'capture',
|
||||||
|
action: (_) async {
|
||||||
|
runCount += 1;
|
||||||
|
return ApplicationResult.success('done');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final duplicate = await unitOfWork.run<String>(
|
||||||
|
context: context,
|
||||||
|
operationName: 'capture',
|
||||||
|
action: (_) async {
|
||||||
|
runCount += 1;
|
||||||
|
return ApplicationResult.success('duplicate');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first.requireValue, 'done');
|
||||||
|
expect(
|
||||||
|
duplicate.failure!.code, ApplicationFailureCode.duplicateOperation);
|
||||||
|
expect(duplicate.failure!.entityId, 'op-1');
|
||||||
|
expect(runCount, 1);
|
||||||
|
expect(unitOfWork.currentOperations, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps domain validation exceptions to stable validation failures',
|
||||||
|
() async {
|
||||||
|
final unitOfWork = InMemoryApplicationUnitOfWork();
|
||||||
|
|
||||||
|
final result = await unitOfWork.run<void>(
|
||||||
|
context: context,
|
||||||
|
operationName: 'bad-task',
|
||||||
|
action: (_) async {
|
||||||
|
Task(
|
||||||
|
id: '',
|
||||||
|
title: 'Bad task',
|
||||||
|
projectId: 'inbox',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.backlog,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
return ApplicationResult.success(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.failure!.code, ApplicationFailureCode.validation);
|
||||||
|
expect(
|
||||||
|
result.failure!.detailCode, DomainValidationCode.blankStableId.name);
|
||||||
|
expect(unitOfWork.currentOperations, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Application scheduling loader', () {
|
||||||
|
test('centralizes repository loading into scheduler input', () async {
|
||||||
|
final now = DateTime.utc(2026, 6, 25, 8);
|
||||||
|
final inWindow = task(
|
||||||
|
id: 'in-window',
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
createdAt: now,
|
||||||
|
scheduledStart: DateTime.utc(2026, 6, 25, 10),
|
||||||
|
scheduledEnd: DateTime.utc(2026, 6, 25, 10, 30),
|
||||||
|
);
|
||||||
|
final outsideWindow = task(
|
||||||
|
id: 'outside-window',
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
createdAt: now,
|
||||||
|
scheduledStart: DateTime.utc(2026, 6, 26, 10),
|
||||||
|
scheduledEnd: DateTime.utc(2026, 6, 26, 10, 30),
|
||||||
|
);
|
||||||
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
||||||
|
initialTasks: [inWindow, outsideWindow],
|
||||||
|
);
|
||||||
|
late SchedulingInput input;
|
||||||
|
|
||||||
|
final result = await unitOfWork.run<void>(
|
||||||
|
context: appContext(operationId: 'load', now: now),
|
||||||
|
operationName: 'load-input',
|
||||||
|
action: (repositories) async {
|
||||||
|
input = await ApplicationSchedulingLoader(repositories)
|
||||||
|
.loadInputForWindow(
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime.utc(2026, 6, 25, 0),
|
||||||
|
end: DateTime.utc(2026, 6, 26, 0),
|
||||||
|
),
|
||||||
|
lockedIntervals: [
|
||||||
|
TimeInterval(
|
||||||
|
start: DateTime.utc(2026, 6, 25, 12),
|
||||||
|
end: DateTime.utc(2026, 6, 25, 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return ApplicationResult.success(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isSuccess, isTrue);
|
||||||
|
expect(input.tasks.map((task) => task.id), ['in-window']);
|
||||||
|
expect(input.lockedIntervals, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationOperationContext appContext({
|
||||||
|
required String operationId,
|
||||||
|
required DateTime now,
|
||||||
|
}) {
|
||||||
|
return ApplicationOperationContext.start(
|
||||||
|
operationId: operationId,
|
||||||
|
ownerTimeZone: OwnerTimeZoneContext(
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
timeZoneId: 'America/Los_Angeles',
|
||||||
|
),
|
||||||
|
clock: FixedClock(now),
|
||||||
|
idGenerator: SequentialIdGenerator(prefix: 'generated'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task task({
|
||||||
|
required String id,
|
||||||
|
required TaskStatus status,
|
||||||
|
required DateTime createdAt,
|
||||||
|
DateTime? scheduledStart,
|
||||||
|
DateTime? scheduledEnd,
|
||||||
|
}) {
|
||||||
|
return Task(
|
||||||
|
id: id,
|
||||||
|
title: 'Task $id',
|
||||||
|
projectId: 'inbox',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: status,
|
||||||
|
durationMinutes: 30,
|
||||||
|
scheduledStart: scheduledStart,
|
||||||
|
scheduledEnd: scheduledEnd,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskActivity activityFor({
|
||||||
|
required String id,
|
||||||
|
required String operationId,
|
||||||
|
required Task task,
|
||||||
|
required DateTime occurredAt,
|
||||||
|
}) {
|
||||||
|
return TaskActivity(
|
||||||
|
id: id,
|
||||||
|
operationId: operationId,
|
||||||
|
code: TaskActivityCode.completed,
|
||||||
|
taskId: task.id,
|
||||||
|
projectId: task.projectId,
|
||||||
|
occurredAt: occurredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue