focus-flow/test/child_tasks_test.dart

489 lines
15 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Child task ownership', () {
final now = DateTime(2026, 6, 19, 12);
test('child references parent through parentTaskId', () {
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(child.parentTaskId, parent.id);
expect(view.isChildOf(child: child, parent: parent), isTrue);
expect(view.parentOf(child), same(parent));
});
test('parent can query and aggregate direct children', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final plannedChild = task(
id: 'planned-child',
title: 'Clear sink',
createdAt: now,
parentTaskId: parent.id,
);
final completedChild = task(
id: 'completed-child',
title: 'Wipe counter',
createdAt: now,
status: TaskStatus.completed,
parentTaskId: parent.id,
);
final unrelatedChild = task(
id: 'unrelated-child',
title: 'Sort drawer',
createdAt: now,
parentTaskId: 'other-parent',
);
final view = ChildTaskView(
tasks: [parent, plannedChild, unrelatedChild, completedChild],
);
expect(view.childrenOf(parent).map((child) => child.id), [
'planned-child',
'completed-child',
]);
final summary = view.summaryFor(parent);
expect(summary.totalCount, 2);
expect(summary.plannedCount, 1);
expect(summary.completedCount, 1);
expect(summary.allChildrenCompleted, isFalse);
});
test('parent can remain incomplete while children are planned or completed',
() {
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,
);
final view = ChildTaskView(tasks: [parent, completedChild]);
expect(parent.status, TaskStatus.planned);
expect(view.summaryFor(parent).allChildrenCompleted, isTrue);
expect(view.parentShouldAutoComplete(parent), isFalse);
});
test('child ownership does not block normal scheduling behavior', () {
const engine = SchedulingEngine();
final parent = task(
id: 'parent',
title: 'Clean kitchen',
createdAt: now,
status: TaskStatus.backlog,
);
final child = task(
id: 'child',
title: 'Clear sink',
createdAt: now,
status: TaskStatus.backlog,
durationMinutes: 15,
parentTaskId: parent.id,
);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [parent, child],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 10),
),
),
taskId: child.id,
updatedAt: now,
);
final scheduledChild = taskById(result.tasks, child.id);
final unchangedParent = taskById(result.tasks, parent.id);
expect(scheduledChild.status, TaskStatus.planned);
expect(scheduledChild.parentTaskId, parent.id);
expect(scheduledChild.scheduledStart, DateTime(2026, 6, 19, 9));
expect(unchangedParent.status, TaskStatus.backlog);
expect(unchangedParent.scheduledStart, isNull);
});
});
group('Child task entry defaults', () {
final now = DateTime(2026, 6, 19, 12);
test('child creation supports explicit row fields', () {
final parent = task(
id: 'parent',
title: 'Clean kitchen',
createdAt: now,
).copyWith(projectId: 'home');
final child = ChildTaskEntry(
id: 'child',
title: ' Clear sink ',
createdAt: now,
priority: PriorityLevel.high,
reward: RewardLevel.high,
difficulty: DifficultyLevel.hard,
durationMinutes: 20,
).toTask(parent: parent);
expect(child.id, 'child');
expect(child.title, 'Clear sink');
expect(child.parentTaskId, parent.id);
expect(child.projectId, parent.projectId);
expect(child.priority, PriorityLevel.high);
expect(child.reward, RewardLevel.high);
expect(child.difficulty, DifficultyLevel.hard);
expect(child.durationMinutes, 20);
expect(child.status, TaskStatus.backlog);
});
test('child inherits parent project unless project override is supplied',
() {
final parent = task(
id: 'parent',
title: 'Plan trip',
createdAt: now,
).copyWith(projectId: 'travel');
final inherited = ChildTaskEntry(
id: 'inherited',
title: 'Book train',
createdAt: now,
).toTask(parent: parent);
final overridden = ChildTaskEntry(
id: 'overridden',
title: 'Ask partner',
createdAt: now,
projectId: 'relationships',
).toTask(parent: parent);
expect(inherited.projectId, 'travel');
expect(overridden.projectId, 'relationships');
});
test('missing reward remains not set and distinct from very low reward',
() {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final noReward = ChildTaskEntry(
id: 'no-reward',
title: 'Clear sink',
createdAt: now,
).toTask(parent: parent);
final veryLowReward = ChildTaskEntry(
id: 'very-low',
title: 'Wipe counter',
createdAt: now,
reward: RewardLevel.veryLow,
).toTask(parent: parent);
expect(noReward.reward, RewardLevel.notSet);
expect(veryLowReward.reward, RewardLevel.veryLow);
expect(noReward.reward, isNot(veryLowReward.reward));
});
test('children without priority preserve row insertion order', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final first = ChildTaskEntry(
id: 'first',
title: 'Clear sink',
createdAt: now,
).toTask(parent: parent);
final second = ChildTaskEntry(
id: 'second',
title: 'Wipe counter',
createdAt: now,
).toTask(parent: parent);
final third = ChildTaskEntry(
id: 'third',
title: 'Sweep floor',
createdAt: now,
).toTask(parent: parent);
final view = ChildTaskView(tasks: [parent, first, second, third]);
expect(first.priority, isNull);
expect(second.priority, isNull);
expect(third.priority, isNull);
expect(view.childrenOf(parent).map((child) => child.id), [
'first',
'second',
'third',
]);
});
});
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({
required String id,
required String title,
required DateTime createdAt,
TaskStatus status = TaskStatus.planned,
int? durationMinutes,
String? parentTaskId,
PriorityLevel? priority = PriorityLevel.medium,
String projectId = 'home',
}) {
return Task(
id: id,
title: title,
projectId: projectId,
type: TaskType.flexible,
status: status,
priority: priority,
durationMinutes: durationMinutes,
parentTaskId: parentTaskId,
createdAt: createdAt,
updatedAt: createdAt,
);
}
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) {
return tasks.singleWhere((task) => task.id == id);
}