From 0b6270a8e7c34d11a08a27f4e95dccbf7af2d8e8 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 24 Jun 2026 12:01:46 -0700 Subject: [PATCH] test(tasks): add child task edge-case regression coverage --- .../V1_BLOCK_07_Child_Tasks.md | 11 + .../V1_BLOCK_07_Child_Tasks.md | 11 + lib/src/child_tasks.dart | 52 ++++ test/child_tasks_test.dart | 248 +++++++++++++++++- 4 files changed, 320 insertions(+), 2 deletions(-) diff --git a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md index 5ecfcb5..131a275 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md @@ -90,6 +90,8 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the child-t Recommended Codex level: extra high +Status: Completed + Tasks: Before implementing parent auto-completion, add or expand tests for edge cases that could make child tasks behave like unwanted dependency logic. @@ -121,6 +123,15 @@ Acceptance criteria: - Tests cover ownership, ordering, inheritance, and non-dependency behavior. - Existing scheduling and backlog tests still pass. +Execution notes: + +- Added a dedicated `Child task edge-case regressions` test group. +- Covered zero-child parent non-auto-completion, planned-child parent remaining incomplete, child completion not forcing parent/sibling completion, parent completion not forcing children before an explicit parent-complete action, parent-id preservation across schedule/push/backlog/complete operations, stable priority sorting, and direct-only ownership. +- Confirmed child project inheritance/override and `RewardLevel.notSet` behavior remain covered by child-entry tests. +- Added `ChildTaskView.childrenOfSortedByPriority` for stable priority ordering of direct children. +- Did not implement parent auto-completion, parent-complete force propagation, dependency/DAG behavior, or scheduling blockers. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation. ## Chunk 7.4 — Parent auto-completion diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md b/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md index 5ecfcb5..131a275 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md @@ -90,6 +90,8 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the child-t Recommended Codex level: extra high +Status: Completed + Tasks: Before implementing parent auto-completion, add or expand tests for edge cases that could make child tasks behave like unwanted dependency logic. @@ -121,6 +123,15 @@ Acceptance criteria: - Tests cover ownership, ordering, inheritance, and non-dependency behavior. - Existing scheduling and backlog tests still pass. +Execution notes: + +- Added a dedicated `Child task edge-case regressions` test group. +- Covered zero-child parent non-auto-completion, planned-child parent remaining incomplete, child completion not forcing parent/sibling completion, parent completion not forcing children before an explicit parent-complete action, parent-id preservation across schedule/push/backlog/complete operations, stable priority sorting, and direct-only ownership. +- Confirmed child project inheritance/override and `RewardLevel.notSet` behavior remain covered by child-entry tests. +- Added `ChildTaskView.childrenOfSortedByPriority` for stable priority ordering of direct children. +- Did not implement parent auto-completion, parent-complete force propagation, dependency/DAG behavior, or scheduling blockers. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation. ## Chunk 7.4 — Parent auto-completion diff --git a/lib/src/child_tasks.dart b/lib/src/child_tasks.dart index 27b96d0..202e450 100644 --- a/lib/src/child_tasks.dart +++ b/lib/src/child_tasks.dart @@ -97,6 +97,37 @@ class ChildTaskView { ); } + /// Direct children sorted by explicit priority while preserving row order ties. + /// + /// Children without priority sort after explicit priorities. Within each + /// priority group, including the no-priority group, source-list order is kept. + List childrenOfSortedByPriority(Task parent) { + final indexedChildren = childrenOf(parent) + .asMap() + .entries + .map( + (entry) => _IndexedChild( + task: entry.value, + originalIndex: entry.key, + ), + ) + .toList(growable: false); + + indexedChildren.sort((a, b) { + final priorityComparison = _priorityRank(b.task.priority) + .compareTo(_priorityRank(a.task.priority)); + if (priorityComparison != 0) { + return priorityComparison; + } + + return a.originalIndex.compareTo(b.originalIndex); + }); + + return List.unmodifiable( + indexedChildren.map((child) => child.task), + ); + } + /// Parent task for [child], or null when the parent is not in [tasks]. Task? parentOf(Task child) { final parentId = child.parentTaskId; @@ -220,3 +251,24 @@ class ChildTaskSummary { /// Whether every direct child is completed. bool get allChildrenCompleted => hasChildren && completedCount == totalCount; } + +class _IndexedChild { + const _IndexedChild({ + required this.task, + required this.originalIndex, + }); + + final Task task; + final int originalIndex; +} + +int _priorityRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryLow => 0, + PriorityLevel.low => 1, + PriorityLevel.medium => 2, + PriorityLevel.high => 3, + PriorityLevel.veryHigh => 4, + null => -1, + }; +} diff --git a/test/child_tasks_test.dart b/test/child_tasks_test.dart index a1c70fa..761e5ea 100644 --- a/test/child_tasks_test.dart +++ b/test/child_tasks_test.dart @@ -216,6 +216,214 @@ void main() { ]); }); }); + + group('Child task edge-case regressions', () { + final now = DateTime(2026, 6, 19, 12); + + test('parent with zero children does not auto-complete by accident', () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final view = ChildTaskView(tasks: [parent]); + + expect(view.childrenOf(parent), isEmpty); + expect(view.summaryFor(parent).hasChildren, isFalse); + expect(view.summaryFor(parent).allChildrenCompleted, isFalse); + expect(view.parentShouldAutoComplete(parent), isFalse); + }); + + test('parent with planned children remains incomplete', () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final child = task( + id: 'child', + title: 'Clear sink', + createdAt: now, + parentTaskId: parent.id, + ); + final view = ChildTaskView(tasks: [parent, child]); + + expect(parent.status, TaskStatus.planned); + expect(view.summaryFor(parent).plannedCount, 1); + expect(view.parentShouldAutoComplete(parent), isFalse); + }); + + test('child completion does not force parent or sibling completion', () { + const service = FlexibleTaskActionService(); + final parent = scheduledTask( + id: 'parent', + title: 'Clean kitchen', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + ); + final firstChild = scheduledTask( + id: 'first-child', + title: 'Clear sink', + createdAt: now, + start: DateTime(2026, 6, 19, 9, 30), + parentTaskId: parent.id, + ); + final secondChild = scheduledTask( + id: 'second-child', + title: 'Wipe counter', + createdAt: now, + start: DateTime(2026, 6, 19, 10), + parentTaskId: parent.id, + ); + + final completedChild = service.apply( + task: firstChild, + action: FlexibleTaskQuickAction.done, + updatedAt: now, + ); + + expect(completedChild.task.status, TaskStatus.completed); + expect(completedChild.task.parentTaskId, parent.id); + expect(parent.status, TaskStatus.planned); + expect(secondChild.status, TaskStatus.planned); + }); + + test('parent completion does not force children before explicit action', + () { + const service = FlexibleTaskActionService(); + final parent = scheduledTask( + id: 'parent', + title: 'Clean kitchen', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + ); + final child = scheduledTask( + id: 'child', + title: 'Clear sink', + createdAt: now, + start: DateTime(2026, 6, 19, 9, 30), + parentTaskId: parent.id, + ); + + final completedParent = service.apply( + task: parent, + action: FlexibleTaskQuickAction.done, + updatedAt: now, + ); + + expect(completedParent.task.status, TaskStatus.completed); + expect(child.status, TaskStatus.planned); + expect(child.parentTaskId, parent.id); + }); + + test('child keeps parent id when scheduled pushed backlogged and completed', + () { + const engine = SchedulingEngine(); + const service = FlexibleTaskActionService(); + final parent = task( + id: 'parent', + title: 'Clean kitchen', + createdAt: now, + status: TaskStatus.backlog, + ); + final backlogChild = task( + id: 'child', + title: 'Clear sink', + createdAt: now, + status: TaskStatus.backlog, + durationMinutes: 15, + parentTaskId: parent.id, + ); + + final scheduledResult = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [parent, backlogChild], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ), + taskId: backlogChild.id, + updatedAt: now, + ); + final scheduledChild = taskById(scheduledResult.tasks, backlogChild.id); + + final pushedResult = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [scheduledChild], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ), + taskId: scheduledChild.id, + updatedAt: now, + ); + final pushedChild = taskById(pushedResult.tasks, backlogChild.id); + final movedToBacklog = engine.moveToBacklog(pushedChild, updatedAt: now); + final completedChild = service.apply( + task: pushedChild, + action: FlexibleTaskQuickAction.done, + updatedAt: now, + ); + + expect(scheduledChild.parentTaskId, parent.id); + expect(pushedChild.parentTaskId, parent.id); + expect(movedToBacklog.parentTaskId, parent.id); + expect(completedChild.task.parentTaskId, parent.id); + }); + + test('priority sorting is stable within same-priority child groups', () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final low = childEntry( + id: 'low', + title: 'Low', + createdAt: now, + priority: PriorityLevel.low, + ).toTask(parent: parent); + final firstHigh = childEntry( + id: 'first-high', + title: 'First high', + createdAt: now, + priority: PriorityLevel.high, + ).toTask(parent: parent); + final secondHigh = childEntry( + id: 'second-high', + title: 'Second high', + createdAt: now, + priority: PriorityLevel.high, + ).toTask(parent: parent); + final noPriority = childEntry( + id: 'no-priority', + title: 'No priority', + createdAt: now, + ).toTask(parent: parent); + final view = ChildTaskView( + tasks: [parent, low, firstHigh, noPriority, secondHigh], + ); + + expect(view.childrenOfSortedByPriority(parent).map((child) => child.id), [ + 'first-high', + 'second-high', + 'low', + 'no-priority', + ]); + }); + + test( + 'ownership stays direct and does not become dependency graph traversal', + () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final child = task( + id: 'child', + title: 'Clear sink', + createdAt: now, + parentTaskId: parent.id, + ); + final grandchild = task( + id: 'grandchild', + title: 'Find sponge', + createdAt: now, + parentTaskId: child.id, + ); + final view = ChildTaskView(tasks: [parent, child, grandchild]); + + expect(view.childrenOf(parent).map((task) => task.id), ['child']); + expect(view.childrenOf(child).map((task) => task.id), ['grandchild']); + }); + }); } Task task({ @@ -225,14 +433,16 @@ Task task({ TaskStatus status = TaskStatus.planned, int? durationMinutes, String? parentTaskId, + PriorityLevel? priority = PriorityLevel.medium, + String projectId = 'home', }) { return Task( id: id, title: title, - projectId: 'home', + projectId: projectId, type: TaskType.flexible, status: status, - priority: PriorityLevel.medium, + priority: priority, durationMinutes: durationMinutes, parentTaskId: parentTaskId, createdAt: createdAt, @@ -240,6 +450,40 @@ Task task({ ); } +Task scheduledTask({ + required String id, + required String title, + required DateTime createdAt, + required DateTime start, + String? parentTaskId, +}) { + return task( + id: id, + title: title, + createdAt: createdAt, + durationMinutes: 15, + parentTaskId: parentTaskId, + ).copyWith( + status: TaskStatus.planned, + scheduledStart: start, + scheduledEnd: start.add(const Duration(minutes: 15)), + ); +} + +ChildTaskEntry childEntry({ + required String id, + required String title, + required DateTime createdAt, + PriorityLevel? priority, +}) { + return ChildTaskEntry( + id: id, + title: title, + createdAt: createdAt, + priority: priority, + ); +} + Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); }