feat(domain): orchestrate child task completion
This commit is contained in:
parent
57b07b8061
commit
be98141ffd
6 changed files with 653 additions and 29 deletions
|
|
@ -38,7 +38,7 @@ passed. The current suite has 143 passing tests.
|
||||||
| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 |
|
| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 |
|
||||||
| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 |
|
| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 |
|
||||||
| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, and push-before-completion values | Project aggregates, child completion patterns, and atomic persistence | 13, 14 |
|
| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, and push-before-completion values | Project aggregates, child completion patterns, and atomic persistence | 13, 14 |
|
||||||
| Child tasks | Entry conversion, ownership views, parent completion propagation | Atomic break-up workflow, cycle/direct-child validation, activity/stat updates, application use case | 13, 14 |
|
| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 |
|
||||||
| Project defaults | Configured static defaults and reminder profile on project | Per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution | 13 |
|
| Project defaults | Configured static defaults and reminder profile on project | Per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution | 13 |
|
||||||
| Reminder profiles | Gentle/persistent/strict/silent enum on project | Task override, effective-profile resolver, free-slot suppression directive; platform delivery remains outside the pure core | 13 |
|
| Reminder profiles | Gentle/persistent/strict/silent enum on project | Task override, effective-profile resolver, free-slot suppression directive; 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 |
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,8 @@ orchestration.
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Complete on 2026-06-25.
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Add a pure domain command/result for breaking one parent into an ordered set of
|
- Add a pure domain command/result for breaking one parent into an ordered set of
|
||||||
|
|
@ -154,6 +156,13 @@ Acceptance criteria:
|
||||||
- Direct-child/cycle constraints are tested.
|
- Direct-child/cycle constraints are tested.
|
||||||
- Result objects contain enough mutation data for atomic persistence.
|
- Result objects contain enough mutation data for atomic persistence.
|
||||||
|
|
||||||
|
Verification on 2026-06-25:
|
||||||
|
|
||||||
|
- `dart format lib test`: passed, 0 files changed after final format
|
||||||
|
- `dart analyze`: passed, no issues found
|
||||||
|
- `dart test`: passed, 225 tests
|
||||||
|
- `git diff --check`: passed
|
||||||
|
|
||||||
BREAKPOINT: Stop here. Confirm `extra high` mode before adding learned project
|
BREAKPOINT: Stop here. Confirm `extra high` mode before adding learned project
|
||||||
statistics and suggestions.
|
statistics and suggestions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ changes from accidental API drift.
|
||||||
|---|---|
|
|---|---|
|
||||||
| `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`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
|
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
|
||||||
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` |
|
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` |
|
||||||
| `src/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` |
|
| `src/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` |
|
||||||
| `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` |
|
| `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` |
|
||||||
|
|
@ -101,7 +101,8 @@ Current notable model fields:
|
||||||
difficulty/reminder profile/duration fields
|
difficulty/reminder profile/duration fields
|
||||||
- `TimeInterval`: `start`, `end`, optional `label`
|
- `TimeInterval`: `start`, `end`, optional `label`
|
||||||
- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour,
|
- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour,
|
||||||
completed-after-shield, push-before-completion, and parent-child counters
|
completed-after-shield, and push-before-completion counters. Parent/child
|
||||||
|
completion patterns are carried as activity metadata.
|
||||||
|
|
||||||
Known baseline gaps to preserve as explicit later work:
|
Known baseline gaps to preserve as explicit later work:
|
||||||
|
|
||||||
|
|
@ -209,6 +210,19 @@ Chunk 13.2 intentionally changed the public API:
|
||||||
- Added `SurpriseTaskLogResult.activities` and routed surprise completion
|
- Added `SurpriseTaskLogResult.activities` and routed surprise completion
|
||||||
through the same accounting boundary.
|
through the same accounting boundary.
|
||||||
|
|
||||||
|
Chunk 13.3 intentionally changed the public API:
|
||||||
|
|
||||||
|
- Added `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, and
|
||||||
|
`ChildTaskBreakUpService`.
|
||||||
|
- Added `activities` and `transitionOutcomeCodes` to
|
||||||
|
`ChildTaskCompletionResult`.
|
||||||
|
- Added `transitionService` injection to `ChildTaskCompletionService`.
|
||||||
|
- Added `operationId`, existing-activity, and applied-activity parameters to
|
||||||
|
child and parent completion methods.
|
||||||
|
- Added `ChildTaskCompletionService.completeParentFromChild`.
|
||||||
|
- Routed parent/child completion through canonical lifecycle transitions and
|
||||||
|
parent/child completion metadata.
|
||||||
|
|
||||||
## 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:
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,13 @@ Latest Block 13.2 verification result on 2026-06-25:
|
||||||
- `dart test`: passed, 218 tests
|
- `dart test`: passed, 218 tests
|
||||||
- `git diff --check`: passed
|
- `git diff --check`: passed
|
||||||
|
|
||||||
|
Latest Block 13.3 verification result on 2026-06-25:
|
||||||
|
|
||||||
|
- `dart format lib test`: passed, 0 files changed after final format
|
||||||
|
- `dart analyze`: passed, no issues found
|
||||||
|
- `dart test`: passed, 225 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.
|
||||||
|
|
@ -66,10 +73,10 @@ Status values:
|
||||||
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. |
|
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. |
|
||||||
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. |
|
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. |
|
||||||
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. |
|
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. |
|
||||||
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskView`, child creation helpers | `test/child_tasks_test.dart` | complete | Atomic application use case 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 | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. |
|
| 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`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunks 13.1-13.2 add canonical activities plus exactly-once task-stat accounting for movement, late, locked-hour, and push-before-completion values; project aggregates and atomic persistence remain in Blocks 13.4 and 14. |
|
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, statistics increment helpers, 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` | incomplete | Chunks 13.1-13.3 add canonical activities, exactly-once task-stat accounting, and parent/child completion metadata; project aggregates and atomic persistence remain in Blocks 13.4 and 14. |
|
||||||
|
|
||||||
## Additional MVP backend requirements
|
## Additional MVP backend requirements
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
// traversal, or UI-specific state.
|
// traversal, or UI-specific state.
|
||||||
|
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
|
import 'task_lifecycle.dart';
|
||||||
import 'time_contracts.dart';
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
/// Row-style input for creating one child task.
|
/// Row-style input for creating one child task.
|
||||||
|
|
@ -78,6 +79,135 @@ class ChildTaskEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Command input for breaking one parent task into direct children.
|
||||||
|
class ChildTaskBreakUpRequest {
|
||||||
|
ChildTaskBreakUpRequest({
|
||||||
|
required String parentTaskId,
|
||||||
|
required List<ChildTaskEntry> entries,
|
||||||
|
required String operationId,
|
||||||
|
}) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'),
|
||||||
|
entries = List<ChildTaskEntry>.unmodifiable(entries),
|
||||||
|
operationId = _requiredTrimmed(operationId, 'operationId');
|
||||||
|
|
||||||
|
/// Parent task that will own every created child.
|
||||||
|
final String parentTaskId;
|
||||||
|
|
||||||
|
/// Ordered child rows supplied by the caller.
|
||||||
|
final List<ChildTaskEntry> entries;
|
||||||
|
|
||||||
|
/// Idempotency key for the application command that creates the children.
|
||||||
|
final String operationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result from breaking a parent task into direct children.
|
||||||
|
class ChildTaskBreakUpResult {
|
||||||
|
ChildTaskBreakUpResult({
|
||||||
|
required List<Task> tasks,
|
||||||
|
required this.parentTask,
|
||||||
|
required List<Task> childTasks,
|
||||||
|
required this.operationId,
|
||||||
|
List<String> createdChildTaskIds = const <String>[],
|
||||||
|
List<TaskActivity> activities = const <TaskActivity>[],
|
||||||
|
}) : tasks = List<Task>.unmodifiable(tasks),
|
||||||
|
childTasks = List<Task>.unmodifiable(childTasks),
|
||||||
|
createdChildTaskIds = List<String>.unmodifiable(createdChildTaskIds),
|
||||||
|
activities = List<TaskActivity>.unmodifiable(activities);
|
||||||
|
|
||||||
|
/// Replacement task list including newly created children.
|
||||||
|
final List<Task> tasks;
|
||||||
|
|
||||||
|
/// Parent that owns the created children.
|
||||||
|
final Task parentTask;
|
||||||
|
|
||||||
|
/// Created children in request order.
|
||||||
|
final List<Task> childTasks;
|
||||||
|
|
||||||
|
/// Idempotency key for the command.
|
||||||
|
final String operationId;
|
||||||
|
|
||||||
|
/// Child ids created by this command.
|
||||||
|
final List<String> createdChildTaskIds;
|
||||||
|
|
||||||
|
/// Activity records produced by this command.
|
||||||
|
///
|
||||||
|
/// V1 child creation is represented by task inserts, so this is currently
|
||||||
|
/// empty while still giving application use cases one atomic mutation shape.
|
||||||
|
final List<TaskActivity> activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates direct child tasks from ordered child-entry rows.
|
||||||
|
class ChildTaskBreakUpService {
|
||||||
|
const ChildTaskBreakUpService();
|
||||||
|
|
||||||
|
/// Break [request.parentTaskId] into direct child tasks.
|
||||||
|
///
|
||||||
|
/// This service validates the full requested set before returning any
|
||||||
|
/// mutations. Persistence and transaction wiring are handled by later blocks.
|
||||||
|
ChildTaskBreakUpResult breakUp({
|
||||||
|
required List<Task> tasks,
|
||||||
|
required ChildTaskBreakUpRequest request,
|
||||||
|
}) {
|
||||||
|
final parent = _taskById(tasks, request.parentTaskId);
|
||||||
|
if (parent == null) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
request.parentTaskId,
|
||||||
|
'parentTaskId',
|
||||||
|
'Parent not found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.entries.isEmpty) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
request.entries,
|
||||||
|
'entries',
|
||||||
|
'At least one child task is required.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final existingIds = tasks.map((task) => task.id).toSet();
|
||||||
|
final requestedIds = <String>{};
|
||||||
|
for (final entry in request.entries) {
|
||||||
|
final childId = entry.id.trim();
|
||||||
|
if (childId.isEmpty) {
|
||||||
|
throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
|
||||||
|
}
|
||||||
|
if (childId == parent.id) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
entry.id,
|
||||||
|
'id',
|
||||||
|
'Child id cannot match the parent id.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!requestedIds.add(childId)) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
entry.id,
|
||||||
|
'id',
|
||||||
|
'Child ids must be unique within one break-up command.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existingIds.contains(childId)) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
entry.id,
|
||||||
|
'id',
|
||||||
|
'Child id is already used by another task.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final childTasks = request.entries
|
||||||
|
.map((entry) => entry.toTask(parent: parent))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
return ChildTaskBreakUpResult(
|
||||||
|
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
|
||||||
|
parentTask: parent,
|
||||||
|
childTasks: childTasks,
|
||||||
|
operationId: request.operationId,
|
||||||
|
createdChildTaskIds:
|
||||||
|
childTasks.map((child) => child.id).toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read-only parent/child projection over a task list.
|
/// Read-only parent/child projection over a task list.
|
||||||
///
|
///
|
||||||
/// This is intentionally not a scheduler. It answers ownership questions such
|
/// This is intentionally not a scheduler. It answers ownership questions such
|
||||||
|
|
@ -172,12 +302,18 @@ class ChildTaskCompletionResult {
|
||||||
this.completedChildId,
|
this.completedChildId,
|
||||||
this.completedParentId,
|
this.completedParentId,
|
||||||
List<String> forceCompletedChildIds = const <String>[],
|
List<String> forceCompletedChildIds = const <String>[],
|
||||||
|
List<TaskActivity> activities = const <TaskActivity>[],
|
||||||
|
List<TaskTransitionOutcomeCode> transitionOutcomeCodes =
|
||||||
|
const <TaskTransitionOutcomeCode>[],
|
||||||
this.autoCompletedParent = false,
|
this.autoCompletedParent = false,
|
||||||
this.canCompleteParentExplicitly = false,
|
this.canCompleteParentExplicitly = false,
|
||||||
}) : tasks = List<Task>.unmodifiable(tasks),
|
}) : tasks = List<Task>.unmodifiable(tasks),
|
||||||
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
|
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
|
||||||
forceCompletedChildIds =
|
forceCompletedChildIds =
|
||||||
List<String>.unmodifiable(forceCompletedChildIds);
|
List<String>.unmodifiable(forceCompletedChildIds),
|
||||||
|
activities = List<TaskActivity>.unmodifiable(activities),
|
||||||
|
transitionOutcomeCodes = List<TaskTransitionOutcomeCode>.unmodifiable(
|
||||||
|
transitionOutcomeCodes);
|
||||||
|
|
||||||
/// Replacement task list after the completion action.
|
/// Replacement task list after the completion action.
|
||||||
final List<Task> tasks;
|
final List<Task> tasks;
|
||||||
|
|
@ -194,6 +330,12 @@ class ChildTaskCompletionResult {
|
||||||
/// Direct child ids completed by explicit parent-complete behavior.
|
/// Direct child ids completed by explicit parent-complete behavior.
|
||||||
final List<String> forceCompletedChildIds;
|
final List<String> forceCompletedChildIds;
|
||||||
|
|
||||||
|
/// Internal activities produced by parent/child completion propagation.
|
||||||
|
final List<TaskActivity> activities;
|
||||||
|
|
||||||
|
/// Transition outcomes returned while applying completion propagation.
|
||||||
|
final List<TaskTransitionOutcomeCode> transitionOutcomeCodes;
|
||||||
|
|
||||||
/// Whether the parent was completed because all direct children are complete.
|
/// Whether the parent was completed because all direct children are complete.
|
||||||
final bool autoCompletedParent;
|
final bool autoCompletedParent;
|
||||||
|
|
||||||
|
|
@ -209,16 +351,23 @@ class ChildTaskCompletionResult {
|
||||||
class ChildTaskCompletionService {
|
class ChildTaskCompletionService {
|
||||||
const ChildTaskCompletionService({
|
const ChildTaskCompletionService({
|
||||||
this.clock = const SystemClock(),
|
this.clock = const SystemClock(),
|
||||||
|
this.transitionService = const TaskTransitionService(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Clock boundary used when a completion timestamp is not supplied.
|
/// Clock boundary used when a completion timestamp is not supplied.
|
||||||
final Clock clock;
|
final Clock clock;
|
||||||
|
|
||||||
|
/// Canonical lifecycle transition dependency.
|
||||||
|
final TaskTransitionService transitionService;
|
||||||
|
|
||||||
/// Complete one child and auto-complete its parent only when all children are complete.
|
/// Complete one child and auto-complete its parent only when all children are complete.
|
||||||
ChildTaskCompletionResult completeChild({
|
ChildTaskCompletionResult completeChild({
|
||||||
required List<Task> tasks,
|
required List<Task> tasks,
|
||||||
required String childTaskId,
|
required String childTaskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
String? operationId,
|
||||||
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||||
|
Iterable<String> appliedActivityIds = const <String>[],
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final child = _taskById(tasks, childTaskId);
|
final child = _taskById(tasks, childTaskId);
|
||||||
|
|
@ -237,27 +386,56 @@ class ChildTaskCompletionService {
|
||||||
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
|
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedChild = _completedTask(child, updatedAt: now);
|
final opId =
|
||||||
final withChildCompleted = _replaceTask(tasks, updatedChild);
|
operationId ?? _defaultOperationId('complete-child', child.id, now);
|
||||||
|
final childTransition = _completeWithTransition(
|
||||||
|
task: child,
|
||||||
|
operationId: opId,
|
||||||
|
occurredAt: now,
|
||||||
|
existingActivities: existingActivities,
|
||||||
|
appliedActivityIds: appliedActivityIds,
|
||||||
|
metadata: {
|
||||||
|
'completionSource': 'child',
|
||||||
|
'parentTaskId': parent.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final withChildCompleted = _replaceTask(tasks, childTransition.task);
|
||||||
final view = ChildTaskView(tasks: withChildCompleted);
|
final view = ChildTaskView(tasks: withChildCompleted);
|
||||||
final shouldCompleteParent = parent.status != TaskStatus.completed &&
|
final shouldCompleteParent = parent.status != TaskStatus.completed &&
|
||||||
view.parentShouldAutoComplete(parent);
|
view.parentShouldAutoComplete(parent);
|
||||||
|
final activities = <TaskActivity>[...childTransition.activities];
|
||||||
|
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
|
||||||
|
|
||||||
if (!shouldCompleteParent) {
|
if (!shouldCompleteParent) {
|
||||||
return ChildTaskCompletionResult(
|
return ChildTaskCompletionResult(
|
||||||
tasks: List<Task>.unmodifiable(withChildCompleted),
|
tasks: List<Task>.unmodifiable(withChildCompleted),
|
||||||
changedTaskIds:
|
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
|
||||||
child.status == TaskStatus.completed ? const [] : [child.id],
|
|
||||||
completedChildId: child.id,
|
completedChildId: child.id,
|
||||||
canCompleteParentExplicitly: parent.status != TaskStatus.completed,
|
canCompleteParentExplicitly: parent.status != TaskStatus.completed,
|
||||||
|
activities: activities,
|
||||||
|
transitionOutcomeCodes: outcomes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedParent = _completedTask(parent, updatedAt: now);
|
final parentTransition = _completeWithTransition(
|
||||||
|
task: parent,
|
||||||
|
operationId: opId,
|
||||||
|
occurredAt: now,
|
||||||
|
existingActivities: existingActivities,
|
||||||
|
appliedActivityIds: appliedActivityIds,
|
||||||
|
metadata: {
|
||||||
|
'completionSource': 'autoParentAfterLastChild',
|
||||||
|
'completedChildTaskId': child.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
activities.addAll(parentTransition.activities);
|
||||||
|
outcomes.add(parentTransition.outcomeCode);
|
||||||
|
|
||||||
|
final updatedParent = parentTransition.task;
|
||||||
final completedTasks = _replaceTask(withChildCompleted, updatedParent);
|
final completedTasks = _replaceTask(withChildCompleted, updatedParent);
|
||||||
final changedTaskIds = <String>[
|
final changedTaskIds = <String>[
|
||||||
if (child.status != TaskStatus.completed) child.id,
|
if (childTransition.applied) child.id,
|
||||||
parent.id,
|
if (parentTransition.applied) parent.id,
|
||||||
];
|
];
|
||||||
|
|
||||||
return ChildTaskCompletionResult(
|
return ChildTaskCompletionResult(
|
||||||
|
|
@ -266,6 +444,41 @@ class ChildTaskCompletionService {
|
||||||
completedChildId: child.id,
|
completedChildId: child.id,
|
||||||
completedParentId: parent.id,
|
completedParentId: parent.id,
|
||||||
autoCompletedParent: true,
|
autoCompletedParent: true,
|
||||||
|
activities: activities,
|
||||||
|
transitionOutcomeCodes: outcomes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a parent from one of its direct children and force-complete siblings.
|
||||||
|
ChildTaskCompletionResult completeParentFromChild({
|
||||||
|
required List<Task> tasks,
|
||||||
|
required String childTaskId,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
String? operationId,
|
||||||
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||||
|
Iterable<String> appliedActivityIds = const <String>[],
|
||||||
|
}) {
|
||||||
|
final child = _taskById(tasks, childTaskId);
|
||||||
|
if (child == null) {
|
||||||
|
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
||||||
|
}
|
||||||
|
final parentId = child.parentTaskId;
|
||||||
|
if (parentId == null) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
childTaskId,
|
||||||
|
'childTaskId',
|
||||||
|
'Task is not a child.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completeParent(
|
||||||
|
tasks: tasks,
|
||||||
|
parentTaskId: parentId,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
operationId: operationId,
|
||||||
|
initiatingChildTaskId: child.id,
|
||||||
|
existingActivities: existingActivities,
|
||||||
|
appliedActivityIds: appliedActivityIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,6 +487,10 @@ class ChildTaskCompletionService {
|
||||||
required List<Task> tasks,
|
required List<Task> tasks,
|
||||||
required String parentTaskId,
|
required String parentTaskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
String? operationId,
|
||||||
|
String? initiatingChildTaskId,
|
||||||
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||||
|
Iterable<String> appliedActivityIds = const <String>[],
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final parent = _taskById(tasks, parentTaskId);
|
final parent = _taskById(tasks, parentTaskId);
|
||||||
|
|
@ -283,15 +500,43 @@ class ChildTaskCompletionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
|
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
|
||||||
|
if (initiatingChildTaskId != null &&
|
||||||
|
!directChildren.any((child) => child.id == initiatingChildTaskId)) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
initiatingChildTaskId,
|
||||||
|
'initiatingChildTaskId',
|
||||||
|
'Initiating task must be a direct child of the parent.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final opId =
|
||||||
|
operationId ?? _defaultOperationId('complete-parent', parent.id, now);
|
||||||
final forceCompletedChildIds = <String>[];
|
final forceCompletedChildIds = <String>[];
|
||||||
final changedTaskIds = <String>[];
|
final changedTaskIds = <String>[];
|
||||||
|
final activities = <TaskActivity>[];
|
||||||
|
final outcomes = <TaskTransitionOutcomeCode>[];
|
||||||
final updatedTasks = <Task>[];
|
final updatedTasks = <Task>[];
|
||||||
|
|
||||||
for (final task in tasks) {
|
for (final task in tasks) {
|
||||||
if (task.id == parent.id) {
|
if (task.id == parent.id) {
|
||||||
final completedParent = _completedTask(task, updatedAt: now);
|
final transition = _completeWithTransition(
|
||||||
|
task: task,
|
||||||
|
operationId: opId,
|
||||||
|
occurredAt: now,
|
||||||
|
existingActivities: existingActivities,
|
||||||
|
appliedActivityIds: appliedActivityIds,
|
||||||
|
metadata: {
|
||||||
|
'completionSource':
|
||||||
|
initiatingChildTaskId == null ? 'parent' : 'parentFromChild',
|
||||||
|
if (initiatingChildTaskId != null)
|
||||||
|
'initiatingChildTaskId': initiatingChildTaskId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final completedParent = transition.task;
|
||||||
updatedTasks.add(completedParent);
|
updatedTasks.add(completedParent);
|
||||||
if (task.status != TaskStatus.completed) {
|
activities.addAll(transition.activities);
|
||||||
|
outcomes.add(transition.outcomeCode);
|
||||||
|
if (transition.applied) {
|
||||||
changedTaskIds.add(task.id);
|
changedTaskIds.add(task.id);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -299,9 +544,28 @@ class ChildTaskCompletionService {
|
||||||
|
|
||||||
final isDirectChild = directChildren.any((child) => child.id == task.id);
|
final isDirectChild = directChildren.any((child) => child.id == task.id);
|
||||||
if (isDirectChild && task.status != TaskStatus.completed) {
|
if (isDirectChild && task.status != TaskStatus.completed) {
|
||||||
updatedTasks.add(_completedTask(task, updatedAt: now));
|
final transition = _completeWithTransition(
|
||||||
|
task: task,
|
||||||
|
operationId: opId,
|
||||||
|
occurredAt: now,
|
||||||
|
existingActivities: existingActivities,
|
||||||
|
appliedActivityIds: appliedActivityIds,
|
||||||
|
metadata: {
|
||||||
|
'completionSource': initiatingChildTaskId == null
|
||||||
|
? 'parentForce'
|
||||||
|
: 'childForceParent',
|
||||||
|
'parentTaskId': parent.id,
|
||||||
|
if (initiatingChildTaskId != null)
|
||||||
|
'initiatingChildTaskId': initiatingChildTaskId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
updatedTasks.add(transition.task);
|
||||||
|
activities.addAll(transition.activities);
|
||||||
|
outcomes.add(transition.outcomeCode);
|
||||||
|
if (transition.applied) {
|
||||||
forceCompletedChildIds.add(task.id);
|
forceCompletedChildIds.add(task.id);
|
||||||
changedTaskIds.add(task.id);
|
changedTaskIds.add(task.id);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,6 +577,34 @@ class ChildTaskCompletionService {
|
||||||
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
||||||
completedParentId: parent.id,
|
completedParentId: parent.id,
|
||||||
forceCompletedChildIds: List<String>.unmodifiable(forceCompletedChildIds),
|
forceCompletedChildIds: List<String>.unmodifiable(forceCompletedChildIds),
|
||||||
|
activities: List<TaskActivity>.unmodifiable(activities),
|
||||||
|
transitionOutcomeCodes: List<TaskTransitionOutcomeCode>.unmodifiable(
|
||||||
|
outcomes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskTransitionResult _completeWithTransition({
|
||||||
|
required Task task,
|
||||||
|
required String operationId,
|
||||||
|
required DateTime occurredAt,
|
||||||
|
required Iterable<TaskActivity> existingActivities,
|
||||||
|
required Iterable<String> appliedActivityIds,
|
||||||
|
required Map<String, Object?> metadata,
|
||||||
|
}) {
|
||||||
|
return transitionService.apply(
|
||||||
|
task: task,
|
||||||
|
transitionCode: TaskTransitionCode.complete,
|
||||||
|
operationId: operationId,
|
||||||
|
activityId: _activityId(
|
||||||
|
operationId,
|
||||||
|
task.id,
|
||||||
|
TaskActivityCode.completed,
|
||||||
|
),
|
||||||
|
occurredAt: occurredAt,
|
||||||
|
existingActivities: existingActivities,
|
||||||
|
appliedActivityIds: appliedActivityIds,
|
||||||
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -439,13 +731,22 @@ List<Task> _replaceTask(List<Task> tasks, Task replacement) {
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Task _completedTask(Task task, {required DateTime updatedAt}) {
|
String _defaultOperationId(String actionName, String taskId, DateTime at) {
|
||||||
if (task.status == TaskStatus.completed && task.updatedAt == updatedAt) {
|
return '$actionName:$taskId:${at.toIso8601String()}';
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return task.copyWith(
|
String _activityId(
|
||||||
status: TaskStatus.completed,
|
String operationId,
|
||||||
updatedAt: updatedAt,
|
String taskId,
|
||||||
);
|
TaskActivityCode activityCode,
|
||||||
|
) {
|
||||||
|
return '$operationId:$taskId:${activityCode.name}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _requiredTrimmed(String value, String name) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
throw ArgumentError.value(value, name, 'Value is required.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,172 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Child task break-up command', () {
|
||||||
|
final now = DateTime(2026, 6, 19, 12);
|
||||||
|
const service = ChildTaskBreakUpService();
|
||||||
|
|
||||||
|
test('creates ordered direct children for one parent', () {
|
||||||
|
final parent = task(
|
||||||
|
id: 'parent',
|
||||||
|
title: 'Clean kitchen',
|
||||||
|
createdAt: now,
|
||||||
|
).copyWith(projectId: 'home');
|
||||||
|
final request = ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
operationId: 'break-parent-1',
|
||||||
|
entries: [
|
||||||
|
childEntry(
|
||||||
|
id: 'first',
|
||||||
|
title: 'Clear sink',
|
||||||
|
createdAt: now,
|
||||||
|
),
|
||||||
|
childEntry(
|
||||||
|
id: 'second',
|
||||||
|
title: 'Wipe counter',
|
||||||
|
createdAt: now,
|
||||||
|
priority: PriorityLevel.high,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.breakUp(tasks: [parent], request: request);
|
||||||
|
|
||||||
|
expect(result.parentTask, parent);
|
||||||
|
expect(result.operationId, 'break-parent-1');
|
||||||
|
expect(result.createdChildTaskIds, ['first', 'second']);
|
||||||
|
expect(result.childTasks.map((child) => child.id), ['first', 'second']);
|
||||||
|
expect(
|
||||||
|
result.childTasks.every((child) => child.parentTaskId == parent.id),
|
||||||
|
isTrue);
|
||||||
|
expect(result.childTasks.every((child) => child.projectId == 'home'),
|
||||||
|
isTrue);
|
||||||
|
expect(result.tasks.map((task) => task.id), [
|
||||||
|
'parent',
|
||||||
|
'first',
|
||||||
|
'second',
|
||||||
|
]);
|
||||||
|
expect(result.activities, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty child sets duplicate ids and reused task ids', () {
|
||||||
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
||||||
|
final existingChild = task(
|
||||||
|
id: 'existing',
|
||||||
|
title: 'Existing child',
|
||||||
|
createdAt: now,
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.breakUp(
|
||||||
|
tasks: [parent],
|
||||||
|
request: ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
operationId: 'empty',
|
||||||
|
entries: const [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.breakUp(
|
||||||
|
tasks: [parent],
|
||||||
|
request: ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
operationId: 'duplicate',
|
||||||
|
entries: [
|
||||||
|
childEntry(id: 'same', title: 'One', createdAt: now),
|
||||||
|
childEntry(id: 'same', title: 'Two', createdAt: now),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.breakUp(
|
||||||
|
tasks: [parent, existingChild],
|
||||||
|
request: ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
operationId: 'reused',
|
||||||
|
entries: [
|
||||||
|
childEntry(id: existingChild.id, title: 'Again', createdAt: now),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects self-parenting and invalid child row fields', () {
|
||||||
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.breakUp(
|
||||||
|
tasks: [parent],
|
||||||
|
request: ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
operationId: 'self',
|
||||||
|
entries: [
|
||||||
|
childEntry(id: parent.id, title: 'Self', createdAt: now),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.breakUp(
|
||||||
|
tasks: [parent],
|
||||||
|
request: ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
operationId: 'blank-title',
|
||||||
|
entries: [
|
||||||
|
childEntry(id: 'child', title: ' ', createdAt: now),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.breakUp(
|
||||||
|
tasks: [parent],
|
||||||
|
request: ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
operationId: 'bad-duration',
|
||||||
|
entries: [
|
||||||
|
ChildTaskEntry(
|
||||||
|
id: 'child',
|
||||||
|
title: 'Clear sink',
|
||||||
|
createdAt: now,
|
||||||
|
durationMinutes: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsA(isA<DomainValidationException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('request snapshots entry lists', () {
|
||||||
|
final entries = [
|
||||||
|
childEntry(id: 'child', title: 'Clear sink', createdAt: now),
|
||||||
|
];
|
||||||
|
|
||||||
|
final request = ChildTaskBreakUpRequest(
|
||||||
|
parentTaskId: 'parent',
|
||||||
|
operationId: 'break',
|
||||||
|
entries: entries,
|
||||||
|
);
|
||||||
|
entries.clear();
|
||||||
|
|
||||||
|
expect(request.entries.map((entry) => entry.id), ['child']);
|
||||||
|
expect(
|
||||||
|
() => request.entries.add(
|
||||||
|
childEntry(id: 'other', title: 'Other', createdAt: now),
|
||||||
|
),
|
||||||
|
throwsUnsupportedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('Child task edge-case regressions', () {
|
group('Child task edge-case regressions', () {
|
||||||
final now = DateTime(2026, 6, 19, 12);
|
final now = DateTime(2026, 6, 19, 12);
|
||||||
|
|
||||||
|
|
@ -438,10 +604,11 @@ void main() {
|
||||||
status: TaskStatus.completed,
|
status: TaskStatus.completed,
|
||||||
parentTaskId: parent.id,
|
parentTaskId: parent.id,
|
||||||
);
|
);
|
||||||
final lastChild = task(
|
final lastChild = scheduledTask(
|
||||||
id: 'last-child',
|
id: 'last-child',
|
||||||
title: 'Wipe counter',
|
title: 'Wipe counter',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 11),
|
||||||
parentTaskId: parent.id,
|
parentTaskId: parent.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -456,6 +623,13 @@ void main() {
|
||||||
expect(result.autoCompletedParent, isTrue);
|
expect(result.autoCompletedParent, isTrue);
|
||||||
expect(result.completedParentId, parent.id);
|
expect(result.completedParentId, parent.id);
|
||||||
expect(result.forceCompletedChildIds, isEmpty);
|
expect(result.forceCompletedChildIds, isEmpty);
|
||||||
|
expect(result.activities.map((activity) => activity.taskId), [
|
||||||
|
'last-child',
|
||||||
|
'parent',
|
||||||
|
]);
|
||||||
|
expect(result.activities.last.metadata['completionSource'],
|
||||||
|
'autoParentAfterLastChild');
|
||||||
|
expect(taskById(result.tasks, 'last-child').stats.completedLateCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parent complete force-completes remaining direct children', () {
|
test('parent complete force-completes remaining direct children', () {
|
||||||
|
|
@ -492,6 +666,15 @@ void main() {
|
||||||
expect(result.forceCompletedChildIds, ['planned-child']);
|
expect(result.forceCompletedChildIds, ['planned-child']);
|
||||||
expect(result.autoCompletedParent, isFalse);
|
expect(result.autoCompletedParent, isFalse);
|
||||||
expect(result.completedParentId, parent.id);
|
expect(result.completedParentId, parent.id);
|
||||||
|
expect(result.activities.map((activity) => activity.taskId), [
|
||||||
|
'parent',
|
||||||
|
'planned-child',
|
||||||
|
]);
|
||||||
|
expect(result.activities.first.metadata['completionSource'], 'parent');
|
||||||
|
expect(
|
||||||
|
result.activities.last.metadata['completionSource'],
|
||||||
|
'parentForce',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('partial child completion does not complete parent', () {
|
test('partial child completion does not complete parent', () {
|
||||||
|
|
@ -576,6 +759,116 @@ void main() {
|
||||||
expect(result.changedTaskIds, ['parent', 'first-child', 'second-child']);
|
expect(result.changedTaskIds, ['parent', 'first-child', 'second-child']);
|
||||||
expect(result.canCompleteParentExplicitly, isFalse);
|
expect(result.canCompleteParentExplicitly, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parent can be completed from a direct child', () {
|
||||||
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
||||||
|
final initiatingChild = task(
|
||||||
|
id: 'initiating-child',
|
||||||
|
title: 'Clear sink',
|
||||||
|
createdAt: now,
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
);
|
||||||
|
final sibling = task(
|
||||||
|
id: 'sibling',
|
||||||
|
title: 'Wipe counter',
|
||||||
|
createdAt: now,
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.completeParentFromChild(
|
||||||
|
tasks: [parent, initiatingChild, sibling],
|
||||||
|
childTaskId: initiatingChild.id,
|
||||||
|
updatedAt: now,
|
||||||
|
operationId: 'complete-from-child',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskById(result.tasks, parent.id).status, TaskStatus.completed);
|
||||||
|
expect(
|
||||||
|
taskById(result.tasks, initiatingChild.id).status,
|
||||||
|
TaskStatus.completed,
|
||||||
|
);
|
||||||
|
expect(taskById(result.tasks, sibling.id).status, TaskStatus.completed);
|
||||||
|
expect(result.forceCompletedChildIds, ['initiating-child', 'sibling']);
|
||||||
|
expect(result.activities.map((activity) => activity.taskId), [
|
||||||
|
'parent',
|
||||||
|
'initiating-child',
|
||||||
|
'sibling',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.activities.first.metadata['initiatingChildTaskId'],
|
||||||
|
initiatingChild.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completion retry with existing activities does not duplicate facts',
|
||||||
|
() {
|
||||||
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
||||||
|
final child = task(
|
||||||
|
id: 'child',
|
||||||
|
title: 'Clear sink',
|
||||||
|
createdAt: now,
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
);
|
||||||
|
final first = service.completeParent(
|
||||||
|
tasks: [parent, child],
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
updatedAt: now,
|
||||||
|
operationId: 'complete-parent-once',
|
||||||
|
);
|
||||||
|
|
||||||
|
final retry = service.completeParent(
|
||||||
|
tasks: first.tasks,
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
updatedAt: now.add(const Duration(minutes: 10)),
|
||||||
|
operationId: 'complete-parent-once',
|
||||||
|
existingActivities: first.activities,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(retry.changedTaskIds, isEmpty);
|
||||||
|
expect(retry.activities, isEmpty);
|
||||||
|
expect(retry.transitionOutcomeCodes, [
|
||||||
|
TaskTransitionOutcomeCode.duplicateOperation,
|
||||||
|
]);
|
||||||
|
expect(taskById(retry.tasks, parent.id).completedAt, now);
|
||||||
|
expect(taskById(retry.tasks, child.id).completedAt, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force completion preserves already terminal child facts', () {
|
||||||
|
final completedAt = now.subtract(const Duration(hours: 1));
|
||||||
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
||||||
|
final completedChild = task(
|
||||||
|
id: 'completed-child',
|
||||||
|
title: 'Clear sink',
|
||||||
|
createdAt: now,
|
||||||
|
status: TaskStatus.completed,
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
).copyWith(
|
||||||
|
updatedAt: completedAt,
|
||||||
|
completedAt: completedAt,
|
||||||
|
);
|
||||||
|
final cancelledChild = task(
|
||||||
|
id: 'cancelled-child',
|
||||||
|
title: 'Wipe counter',
|
||||||
|
createdAt: now,
|
||||||
|
status: TaskStatus.cancelled,
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.completeParent(
|
||||||
|
tasks: [parent, completedChild, cancelledChild],
|
||||||
|
parentTaskId: parent.id,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.forceCompletedChildIds, isEmpty);
|
||||||
|
expect(
|
||||||
|
taskById(result.tasks, completedChild.id).completedAt, completedAt);
|
||||||
|
expect(
|
||||||
|
taskById(result.tasks, cancelledChild.id).status,
|
||||||
|
TaskStatus.cancelled,
|
||||||
|
);
|
||||||
|
expect(result.activities.map((activity) => activity.taskId), ['parent']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue