focus-flow/test/project_statistics_test.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,
);
}