feat(app): add application unit of work foundation

This commit is contained in:
Ashley Venn 2026-06-25 10:17:14 -07:00
parent edfb0cbba5
commit 2463fd7817
7 changed files with 1169 additions and 6 deletions

View file

@ -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 |

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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';

View 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;
}

View 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,
);
}