310 lines
11 KiB
Dart
310 lines
11 KiB
Dart
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('Project statistics aggregation', () {
|
|
final createdAt = DateTime.utc(2026, 6, 25, 8);
|
|
const transitionService = TaskTransitionService();
|
|
const aggregationService = ProjectStatisticsAggregationService();
|
|
|
|
test('applies completed task activities exactly once', () {
|
|
final task = scheduledTask(
|
|
id: 'task-1',
|
|
projectId: 'home',
|
|
createdAt: createdAt,
|
|
start: DateTime.utc(2026, 6, 25, 9),
|
|
reward: RewardLevel.high,
|
|
difficulty: DifficultyLevel.hard,
|
|
reminderOverride: ReminderProfile.strict,
|
|
).copyWith(
|
|
stats: const TaskStatistics(
|
|
manuallyPushedCount: 1,
|
|
autoPushedCount: 2,
|
|
),
|
|
);
|
|
final transition = transitionService.apply(
|
|
task: task,
|
|
transitionCode: TaskTransitionCode.complete,
|
|
operationId: 'complete-task-1',
|
|
activityId: 'activity-1',
|
|
occurredAt: DateTime.utc(2026, 6, 25, 10),
|
|
actualStart: DateTime.utc(2026, 6, 25, 9, 5),
|
|
actualEnd: DateTime.utc(2026, 6, 25, 9, 25),
|
|
);
|
|
|
|
final first = aggregationService.apply(
|
|
statistics: ProjectStatistics(projectId: 'home'),
|
|
activities: transition.activities,
|
|
tasks: [transition.task],
|
|
);
|
|
final retry = aggregationService.apply(
|
|
statistics: first.statistics,
|
|
activities: transition.activities,
|
|
tasks: [transition.task],
|
|
alreadyAppliedActivityIds: first.appliedActivityIds,
|
|
);
|
|
|
|
expect(first.appliedActivityIds, ['activity-1']);
|
|
expect(first.statistics.completedTaskCount, 1);
|
|
expect(first.statistics.durationMinuteCounts, {20: 1});
|
|
expect(first.statistics.completionTimeBucketCounts, {
|
|
ProjectCompletionTimeBucket.morning: 1,
|
|
});
|
|
expect(first.statistics.totalPushesBeforeCompletion, 3);
|
|
expect(first.statistics.completedAfterPushCount, 1);
|
|
expect(first.statistics.rewardCounts, {RewardLevel.high: 1});
|
|
expect(first.statistics.difficultyCounts, {DifficultyLevel.hard: 1});
|
|
expect(first.statistics.reminderProfileCounts, {
|
|
ReminderProfile.strict: 1,
|
|
});
|
|
expect(first.statistics.averagePushesBeforeCompletionNumerator, 3);
|
|
expect(first.statistics.averagePushesBeforeCompletionDenominator, 1);
|
|
expect(first.statistics.averagePushesBeforeCompletion, 3);
|
|
|
|
expect(retry.appliedActivityIds, isEmpty);
|
|
expect(retry.statistics.completedTaskCount, 1);
|
|
expect(retry.statistics.totalPushesBeforeCompletion, 3);
|
|
});
|
|
|
|
test('skips non-completion and other-project activities', () {
|
|
final completed = TaskActivity(
|
|
id: 'completed',
|
|
operationId: 'complete',
|
|
code: TaskActivityCode.completed,
|
|
taskId: 'task-1',
|
|
projectId: 'home',
|
|
occurredAt: DateTime.utc(2026, 6, 25, 9),
|
|
metadata: const {'durationMinutes': 25},
|
|
);
|
|
final pushed = TaskActivity(
|
|
id: 'pushed',
|
|
operationId: 'push',
|
|
code: TaskActivityCode.manuallyPushed,
|
|
taskId: 'task-1',
|
|
projectId: 'home',
|
|
occurredAt: DateTime.utc(2026, 6, 25, 8),
|
|
);
|
|
final otherProject = TaskActivity(
|
|
id: 'other-project',
|
|
operationId: 'complete-other',
|
|
code: TaskActivityCode.completed,
|
|
taskId: 'task-2',
|
|
projectId: 'work',
|
|
occurredAt: DateTime.utc(2026, 6, 25, 10),
|
|
);
|
|
|
|
final result = aggregationService.apply(
|
|
statistics: ProjectStatistics(projectId: 'home'),
|
|
activities: [completed, pushed, otherProject, completed],
|
|
);
|
|
|
|
expect(result.appliedActivityIds, ['completed']);
|
|
expect(result.statistics.completedTaskCount, 1);
|
|
expect(result.statistics.durationMinuteCounts, {25: 1});
|
|
});
|
|
});
|
|
|
|
group('Project learned suggestions', () {
|
|
const service = ProjectSuggestionService();
|
|
|
|
test('uses deterministic samples and resists duration outliers', () {
|
|
final statistics = ProjectStatistics(
|
|
projectId: 'home',
|
|
completedTaskCount: 4,
|
|
durationMinuteCounts: const {25: 2, 30: 1, 240: 1},
|
|
completionTimeBucketCounts: const {
|
|
ProjectCompletionTimeBucket.morning: 3,
|
|
ProjectCompletionTimeBucket.evening: 1,
|
|
},
|
|
rewardCounts: const {
|
|
RewardLevel.high: 2,
|
|
RewardLevel.low: 1,
|
|
RewardLevel.notSet: 10,
|
|
},
|
|
difficultyCounts: const {
|
|
DifficultyLevel.medium: 3,
|
|
DifficultyLevel.hard: 1,
|
|
},
|
|
reminderProfileCounts: const {
|
|
ReminderProfile.persistent: 3,
|
|
ReminderProfile.gentle: 1,
|
|
},
|
|
);
|
|
|
|
final suggestions = service.suggestionsFor(statistics);
|
|
|
|
expect(suggestions.durationMinutes!.value, 25);
|
|
expect(suggestions.durationMinutes!.sampleSize, 4);
|
|
expect(suggestions.durationMinutes!.supportingSampleCount, 2);
|
|
expect(
|
|
suggestions.completionTimeBucket!.value,
|
|
ProjectCompletionTimeBucket.morning,
|
|
);
|
|
expect(suggestions.completionTimeBucket!.confidence,
|
|
ProjectSuggestionConfidence.high);
|
|
expect(suggestions.reward!.value, RewardLevel.high);
|
|
expect(suggestions.reward!.sampleSize, 3);
|
|
expect(suggestions.difficulty!.value, DifficultyLevel.medium);
|
|
expect(suggestions.reminderProfile!.value, ReminderProfile.persistent);
|
|
});
|
|
|
|
test('does not suggest when samples are insufficient', () {
|
|
final statistics = ProjectStatistics(
|
|
projectId: 'home',
|
|
completedTaskCount: 2,
|
|
durationMinuteCounts: const {25: 2},
|
|
completionTimeBucketCounts: const {
|
|
ProjectCompletionTimeBucket.morning: 2,
|
|
},
|
|
rewardCounts: const {RewardLevel.high: 2},
|
|
difficultyCounts: const {DifficultyLevel.medium: 2},
|
|
reminderProfileCounts: const {ReminderProfile.gentle: 2},
|
|
);
|
|
|
|
final suggestions = service.suggestionsFor(statistics);
|
|
|
|
expect(suggestions.durationMinutes, isNull);
|
|
expect(suggestions.completionTimeBucket, isNull);
|
|
expect(suggestions.reward, isNull);
|
|
expect(suggestions.difficulty, isNull);
|
|
expect(suggestions.reminderProfile, isNull);
|
|
});
|
|
|
|
test('does not choose categorical suggestions on ties', () {
|
|
final statistics = ProjectStatistics(
|
|
projectId: 'home',
|
|
completedTaskCount: 4,
|
|
completionTimeBucketCounts: const {
|
|
ProjectCompletionTimeBucket.morning: 2,
|
|
ProjectCompletionTimeBucket.evening: 2,
|
|
},
|
|
rewardCounts: const {
|
|
RewardLevel.high: 2,
|
|
RewardLevel.low: 2,
|
|
},
|
|
difficultyCounts: const {
|
|
DifficultyLevel.easy: 2,
|
|
DifficultyLevel.hard: 2,
|
|
},
|
|
reminderProfileCounts: const {
|
|
ReminderProfile.gentle: 2,
|
|
ReminderProfile.persistent: 2,
|
|
},
|
|
);
|
|
|
|
final suggestions = service.suggestionsFor(statistics);
|
|
|
|
expect(suggestions.completionTimeBucket, isNull);
|
|
expect(suggestions.reward, isNull);
|
|
expect(suggestions.difficulty, isNull);
|
|
expect(suggestions.reminderProfile, isNull);
|
|
});
|
|
|
|
test('stable recomputation is independent of observation order', () {
|
|
final first = ProjectStatistics(projectId: 'home')
|
|
.recordCompletion(
|
|
completedAt: DateTime.utc(2026, 6, 25, 9),
|
|
durationMinutes: 20,
|
|
reward: RewardLevel.high,
|
|
)
|
|
.recordCompletion(
|
|
completedAt: DateTime.utc(2026, 6, 25, 18),
|
|
durationMinutes: 40,
|
|
reward: RewardLevel.low,
|
|
)
|
|
.recordCompletion(
|
|
completedAt: DateTime.utc(2026, 6, 25, 10),
|
|
durationMinutes: 20,
|
|
reward: RewardLevel.high,
|
|
);
|
|
final second = ProjectStatistics(projectId: 'home')
|
|
.recordCompletion(
|
|
completedAt: DateTime.utc(2026, 6, 25, 18),
|
|
durationMinutes: 40,
|
|
reward: RewardLevel.low,
|
|
)
|
|
.recordCompletion(
|
|
completedAt: DateTime.utc(2026, 6, 25, 10),
|
|
durationMinutes: 20,
|
|
reward: RewardLevel.high,
|
|
)
|
|
.recordCompletion(
|
|
completedAt: DateTime.utc(2026, 6, 25, 9),
|
|
durationMinutes: 20,
|
|
reward: RewardLevel.high,
|
|
);
|
|
|
|
final firstSuggestions = service.suggestionsFor(first);
|
|
final secondSuggestions = service.suggestionsFor(second);
|
|
|
|
expect(firstSuggestions.durationMinutes!.value, 20);
|
|
expect(secondSuggestions.durationMinutes!.value, 20);
|
|
expect(firstSuggestions.reward!.value, RewardLevel.high);
|
|
expect(secondSuggestions.reward!.value, RewardLevel.high);
|
|
});
|
|
|
|
test('configured defaults remain authoritative beside suggestions', () {
|
|
final project = ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'green',
|
|
defaultReward: RewardLevel.high,
|
|
defaultDifficulty: DifficultyLevel.hard,
|
|
defaultReminderProfile: ReminderProfile.strict,
|
|
defaultDurationMinutes: 45,
|
|
);
|
|
final statistics = ProjectStatistics(
|
|
projectId: 'home',
|
|
completedTaskCount: 3,
|
|
durationMinuteCounts: const {20: 3},
|
|
rewardCounts: const {RewardLevel.low: 3},
|
|
difficultyCounts: const {DifficultyLevel.easy: 3},
|
|
reminderProfileCounts: const {ReminderProfile.gentle: 3},
|
|
);
|
|
|
|
final resolution = service.resolveDefaults(
|
|
project: project,
|
|
statistics: statistics,
|
|
);
|
|
|
|
expect(resolution.configuredDurationMinutes, 45);
|
|
expect(resolution.configuredReward, RewardLevel.high);
|
|
expect(resolution.configuredDifficulty, DifficultyLevel.hard);
|
|
expect(resolution.configuredReminderProfile, ReminderProfile.strict);
|
|
expect(resolution.suggestions.durationMinutes!.value, 20);
|
|
expect(resolution.suggestions.reward!.value, RewardLevel.low);
|
|
expect(resolution.suggestions.difficulty!.value, DifficultyLevel.easy);
|
|
expect(
|
|
resolution.suggestions.reminderProfile!.value,
|
|
ReminderProfile.gentle,
|
|
);
|
|
expect(project.defaultReward, RewardLevel.high);
|
|
});
|
|
});
|
|
}
|
|
|
|
Task scheduledTask({
|
|
required String id,
|
|
required String projectId,
|
|
required DateTime createdAt,
|
|
required DateTime start,
|
|
RewardLevel reward = RewardLevel.notSet,
|
|
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
|
ReminderProfile? reminderOverride,
|
|
}) {
|
|
return Task(
|
|
id: id,
|
|
title: 'Task',
|
|
projectId: projectId,
|
|
type: TaskType.flexible,
|
|
status: TaskStatus.planned,
|
|
reward: reward,
|
|
difficulty: difficulty,
|
|
durationMinutes: 30,
|
|
scheduledStart: start,
|
|
scheduledEnd: start.add(const Duration(minutes: 30)),
|
|
reminderOverride: reminderOverride,
|
|
createdAt: createdAt,
|
|
updatedAt: createdAt,
|
|
);
|
|
}
|