935 lines
28 KiB
Dart
935 lines
28 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), isTrue);
|
|
});
|
|
|
|
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 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', () {
|
|
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']);
|
|
});
|
|
});
|
|
|
|
group('Parent child completion rules', () {
|
|
final now = DateTime(2026, 6, 19, 12);
|
|
const service = ChildTaskCompletionService();
|
|
|
|
test('all children completed completes parent', () {
|
|
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 lastChild = scheduledTask(
|
|
id: 'last-child',
|
|
title: 'Wipe counter',
|
|
createdAt: now,
|
|
start: DateTime(2026, 6, 19, 11),
|
|
parentTaskId: parent.id,
|
|
);
|
|
|
|
final result = service.completeChild(
|
|
tasks: [parent, completedChild, lastChild],
|
|
childTaskId: lastChild.id,
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(taskById(result.tasks, 'last-child').status, TaskStatus.completed);
|
|
expect(taskById(result.tasks, 'parent').status, TaskStatus.completed);
|
|
expect(result.autoCompletedParent, isTrue);
|
|
expect(result.completedParentId, parent.id);
|
|
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', () {
|
|
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 result = service.completeParent(
|
|
tasks: [parent, plannedChild, completedChild],
|
|
parentTaskId: parent.id,
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(taskById(result.tasks, 'parent').status, TaskStatus.completed);
|
|
expect(
|
|
taskById(result.tasks, 'planned-child').status,
|
|
TaskStatus.completed,
|
|
);
|
|
expect(
|
|
taskById(result.tasks, 'completed-child').status,
|
|
TaskStatus.completed,
|
|
);
|
|
expect(result.forceCompletedChildIds, ['planned-child']);
|
|
expect(result.autoCompletedParent, isFalse);
|
|
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', () {
|
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
|
final firstChild = task(
|
|
id: 'first-child',
|
|
title: 'Clear sink',
|
|
createdAt: now,
|
|
parentTaskId: parent.id,
|
|
);
|
|
final secondChild = task(
|
|
id: 'second-child',
|
|
title: 'Wipe counter',
|
|
createdAt: now,
|
|
parentTaskId: parent.id,
|
|
);
|
|
|
|
final result = service.completeChild(
|
|
tasks: [parent, firstChild, secondChild],
|
|
childTaskId: firstChild.id,
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(
|
|
taskById(result.tasks, 'first-child').status, TaskStatus.completed);
|
|
expect(taskById(result.tasks, 'parent').status, TaskStatus.planned);
|
|
expect(result.autoCompletedParent, isFalse);
|
|
expect(result.canCompleteParentExplicitly, isTrue);
|
|
});
|
|
|
|
test('completing one child does not complete siblings', () {
|
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
|
final firstChild = task(
|
|
id: 'first-child',
|
|
title: 'Clear sink',
|
|
createdAt: now,
|
|
parentTaskId: parent.id,
|
|
);
|
|
final secondChild = task(
|
|
id: 'second-child',
|
|
title: 'Wipe counter',
|
|
createdAt: now,
|
|
parentTaskId: parent.id,
|
|
);
|
|
|
|
final result = service.completeChild(
|
|
tasks: [parent, firstChild, secondChild],
|
|
childTaskId: firstChild.id,
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(
|
|
taskById(result.tasks, 'first-child').status, TaskStatus.completed);
|
|
expect(taskById(result.tasks, 'second-child').status, TaskStatus.planned);
|
|
expect(result.forceCompletedChildIds, isEmpty);
|
|
});
|
|
|
|
test('explicit parent-complete action records parent and child completion',
|
|
() {
|
|
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
|
|
final firstChild = task(
|
|
id: 'first-child',
|
|
title: 'Clear sink',
|
|
createdAt: now,
|
|
parentTaskId: parent.id,
|
|
);
|
|
final secondChild = task(
|
|
id: 'second-child',
|
|
title: 'Wipe counter',
|
|
createdAt: now,
|
|
parentTaskId: parent.id,
|
|
);
|
|
|
|
final result = service.completeParent(
|
|
tasks: [parent, firstChild, secondChild],
|
|
parentTaskId: parent.id,
|
|
updatedAt: now,
|
|
);
|
|
|
|
expect(result.completedParentId, parent.id);
|
|
expect(result.forceCompletedChildIds, ['first-child', 'second-child']);
|
|
expect(result.changedTaskIds, ['parent', 'first-child', 'second-child']);
|
|
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']);
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|