test(tasks): add child task edge-case regression coverage
This commit is contained in:
parent
eba197d5ae
commit
0b6270a8e7
4 changed files with 320 additions and 2 deletions
|
|
@ -90,6 +90,8 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the child-t
|
||||||
|
|
||||||
Recommended Codex level: extra high
|
Recommended Codex level: extra high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Before implementing parent auto-completion, add or expand tests for edge cases that could make child tasks behave like unwanted dependency logic.
|
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.
|
- Tests cover ownership, ordering, inheritance, and non-dependency behavior.
|
||||||
- Existing scheduling and backlog tests still pass.
|
- 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.
|
BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation.
|
||||||
|
|
||||||
## Chunk 7.4 — Parent auto-completion
|
## Chunk 7.4 — Parent auto-completion
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the child-t
|
||||||
|
|
||||||
Recommended Codex level: extra high
|
Recommended Codex level: extra high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Before implementing parent auto-completion, add or expand tests for edge cases that could make child tasks behave like unwanted dependency logic.
|
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.
|
- Tests cover ownership, ordering, inheritance, and non-dependency behavior.
|
||||||
- Existing scheduling and backlog tests still pass.
|
- 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.
|
BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation.
|
||||||
|
|
||||||
## Chunk 7.4 — Parent auto-completion
|
## Chunk 7.4 — Parent auto-completion
|
||||||
|
|
|
||||||
|
|
@ -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<Task> 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<Task>.unmodifiable(
|
||||||
|
indexedChildren.map((child) => child.task),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Parent task for [child], or null when the parent is not in [tasks].
|
/// Parent task for [child], or null when the parent is not in [tasks].
|
||||||
Task? parentOf(Task child) {
|
Task? parentOf(Task child) {
|
||||||
final parentId = child.parentTaskId;
|
final parentId = child.parentTaskId;
|
||||||
|
|
@ -220,3 +251,24 @@ class ChildTaskSummary {
|
||||||
/// Whether every direct child is completed.
|
/// Whether every direct child is completed.
|
||||||
bool get allChildrenCompleted => hasChildren && completedCount == totalCount;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
Task task({
|
||||||
|
|
@ -225,14 +433,16 @@ Task task({
|
||||||
TaskStatus status = TaskStatus.planned,
|
TaskStatus status = TaskStatus.planned,
|
||||||
int? durationMinutes,
|
int? durationMinutes,
|
||||||
String? parentTaskId,
|
String? parentTaskId,
|
||||||
|
PriorityLevel? priority = PriorityLevel.medium,
|
||||||
|
String projectId = 'home',
|
||||||
}) {
|
}) {
|
||||||
return Task(
|
return Task(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
projectId: 'home',
|
projectId: projectId,
|
||||||
type: TaskType.flexible,
|
type: TaskType.flexible,
|
||||||
status: status,
|
status: status,
|
||||||
priority: PriorityLevel.medium,
|
priority: priority,
|
||||||
durationMinutes: durationMinutes,
|
durationMinutes: durationMinutes,
|
||||||
parentTaskId: parentTaskId,
|
parentTaskId: parentTaskId,
|
||||||
createdAt: createdAt,
|
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<Task> tasks, String id) {
|
Task taskById(List<Task> tasks, String id) {
|
||||||
return tasks.singleWhere((task) => task.id == id);
|
return tasks.singleWhere((task) => task.id == id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue