forked from eva/focus-flow
320 lines
9.7 KiB
Dart
320 lines
9.7 KiB
Dart
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('Effective reminder profile resolution', () {
|
|
const service = ReminderPolicyService();
|
|
final now = DateTime.utc(2026, 6, 25, 9);
|
|
|
|
test('uses task override before project default and fallback', () {
|
|
final project = ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'green',
|
|
defaultReminderProfile: ReminderProfile.gentle,
|
|
);
|
|
final task = scheduledTask(
|
|
id: 'task',
|
|
now: now,
|
|
reminderOverride: ReminderProfile.strict,
|
|
);
|
|
|
|
final effective = service.resolveEffectiveProfile(
|
|
task: task,
|
|
project: project,
|
|
);
|
|
|
|
expect(effective.profile, ReminderProfile.strict);
|
|
expect(effective.source, EffectiveReminderProfileSource.taskOverride);
|
|
});
|
|
|
|
test('uses project default before application fallback', () {
|
|
final project = ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'green',
|
|
defaultReminderProfile: ReminderProfile.persistent,
|
|
);
|
|
final task = scheduledTask(id: 'task', now: now);
|
|
|
|
final effective = service.resolveEffectiveProfile(
|
|
task: task,
|
|
project: project,
|
|
);
|
|
|
|
expect(effective.profile, ReminderProfile.persistent);
|
|
expect(effective.source, EffectiveReminderProfileSource.projectDefault);
|
|
});
|
|
|
|
test('uses fallback when no task or project value exists', () {
|
|
final task = scheduledTask(id: 'task', now: now);
|
|
|
|
final effective = service.resolveEffectiveProfile(task: task);
|
|
|
|
expect(effective.profile, ReminderProfile.gentle);
|
|
expect(effective.source, EffectiveReminderProfileSource.fallback);
|
|
});
|
|
|
|
test('does not silently use learned project suggestions', () {
|
|
final project = ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'green',
|
|
defaultReminderProfile: ReminderProfile.strict,
|
|
);
|
|
final statistics = ProjectStatistics(
|
|
projectId: 'home',
|
|
completedTaskCount: 3,
|
|
reminderProfileCounts: const {ReminderProfile.gentle: 3},
|
|
);
|
|
final suggestionResolution = const ProjectSuggestionService()
|
|
.resolveDefaults(project: project, statistics: statistics);
|
|
final task = scheduledTask(id: 'task', now: now);
|
|
|
|
final effective = service.resolveEffectiveProfile(
|
|
task: task,
|
|
project: suggestionResolution.project,
|
|
);
|
|
|
|
expect(suggestionResolution.suggestions.reminderProfile!.value,
|
|
ReminderProfile.gentle);
|
|
expect(effective.profile, ReminderProfile.strict);
|
|
expect(effective.source, EffectiveReminderProfileSource.projectDefault);
|
|
});
|
|
});
|
|
|
|
group('Reminder directives', () {
|
|
const service = ReminderPolicyService();
|
|
final now = DateTime.utc(2026, 6, 25, 9);
|
|
|
|
test('all non-silent reminder profiles produce normal flexible delivery',
|
|
() {
|
|
for (final profile in [
|
|
ReminderProfile.gentle,
|
|
ReminderProfile.persistent,
|
|
ReminderProfile.strict,
|
|
]) {
|
|
final directive = service.directiveForTask(
|
|
task: scheduledTask(
|
|
id: 'task-${profile.name}',
|
|
now: now,
|
|
reminderOverride: profile,
|
|
),
|
|
now: now,
|
|
);
|
|
|
|
expect(directive.action, ReminderDirectiveAction.deliver);
|
|
expect(directive.reason, ReminderDirectiveReason.deliverNormal);
|
|
expect(directive.effectiveProfile, profile);
|
|
}
|
|
});
|
|
|
|
test('silent profile suppresses normal reminders', () {
|
|
final directive = service.directiveForTask(
|
|
task: scheduledTask(
|
|
id: 'task',
|
|
now: now,
|
|
reminderOverride: ReminderProfile.silent,
|
|
),
|
|
now: now,
|
|
);
|
|
|
|
expect(directive.action, ReminderDirectiveAction.suppress);
|
|
expect(directive.reason, ReminderDirectiveReason.suppressSilentProfile);
|
|
});
|
|
|
|
test(
|
|
'defers before the scheduled window and delivers at the start boundary',
|
|
() {
|
|
final task = scheduledTask(
|
|
id: 'task',
|
|
now: now,
|
|
start: DateTime.utc(2026, 6, 25, 10),
|
|
);
|
|
|
|
final before = service.directiveForTask(
|
|
task: task,
|
|
now: DateTime.utc(2026, 6, 25, 9, 59),
|
|
);
|
|
final atStart = service.directiveForTask(
|
|
task: task,
|
|
now: DateTime.utc(2026, 6, 25, 10),
|
|
);
|
|
|
|
expect(before.action, ReminderDirectiveAction.defer);
|
|
expect(before.reason, ReminderDirectiveReason.deferUntilTaskWindow);
|
|
expect(before.nextEligibleAt, DateTime.utc(2026, 6, 25, 10));
|
|
expect(atStart.action, ReminderDirectiveAction.deliver);
|
|
});
|
|
|
|
test('suppresses flexible reminders during active protected Free Slots',
|
|
() {
|
|
final freeSlot = freeSlotTask(
|
|
id: 'rest',
|
|
start: DateTime.utc(2026, 6, 25, 9),
|
|
end: DateTime.utc(2026, 6, 25, 9, 30),
|
|
createdAt: now,
|
|
);
|
|
final flexible = scheduledTask(id: 'flexible', now: now);
|
|
|
|
final atStart = service.directiveForTask(
|
|
task: flexible,
|
|
now: DateTime.utc(2026, 6, 25, 9),
|
|
contextTasks: [freeSlot, flexible],
|
|
);
|
|
final inside = service.directiveForTask(
|
|
task: flexible,
|
|
now: DateTime.utc(2026, 6, 25, 9, 29),
|
|
contextTasks: [freeSlot, flexible],
|
|
);
|
|
final atEnd = service.directiveForTask(
|
|
task: flexible,
|
|
now: DateTime.utc(2026, 6, 25, 9, 30),
|
|
contextTasks: [freeSlot, flexible],
|
|
);
|
|
|
|
expect(atStart.action, ReminderDirectiveAction.suppress);
|
|
expect(atStart.reason, ReminderDirectiveReason.suppressProtectedRest);
|
|
expect(atStart.protectedFreeSlotTaskId, 'rest');
|
|
expect(atStart.nextEligibleAt, DateTime.utc(2026, 6, 25, 9, 30));
|
|
expect(inside.action, ReminderDirectiveAction.suppress);
|
|
expect(atEnd.action, ReminderDirectiveAction.deliver);
|
|
});
|
|
|
|
test('required reminders may pass protected rest by typed policy', () {
|
|
final freeSlot = freeSlotTask(
|
|
id: 'rest',
|
|
start: DateTime.utc(2026, 6, 25, 9),
|
|
end: DateTime.utc(2026, 6, 25, 10),
|
|
createdAt: now,
|
|
);
|
|
final critical = scheduledTask(
|
|
id: 'critical',
|
|
now: now,
|
|
type: TaskType.critical,
|
|
reminderOverride: ReminderProfile.strict,
|
|
);
|
|
final inflexiblePersistent = scheduledTask(
|
|
id: 'inflexible-persistent',
|
|
now: now,
|
|
type: TaskType.inflexible,
|
|
reminderOverride: ReminderProfile.persistent,
|
|
);
|
|
final inflexibleGentle = scheduledTask(
|
|
id: 'inflexible-gentle',
|
|
now: now,
|
|
type: TaskType.inflexible,
|
|
reminderOverride: ReminderProfile.gentle,
|
|
);
|
|
|
|
final criticalDirective = service.directiveForTask(
|
|
task: critical,
|
|
now: now,
|
|
contextTasks: [freeSlot, critical],
|
|
);
|
|
final persistentDirective = service.directiveForTask(
|
|
task: inflexiblePersistent,
|
|
now: now,
|
|
contextTasks: [freeSlot, inflexiblePersistent],
|
|
);
|
|
final gentleDirective = service.directiveForTask(
|
|
task: inflexibleGentle,
|
|
now: now,
|
|
contextTasks: [freeSlot, inflexibleGentle],
|
|
);
|
|
|
|
expect(
|
|
criticalDirective.action,
|
|
ReminderDirectiveAction.requireAcknowledgement,
|
|
);
|
|
expect(
|
|
criticalDirective.reason,
|
|
ReminderDirectiveReason.requireRequiredAcknowledgement,
|
|
);
|
|
expect(persistentDirective.action, ReminderDirectiveAction.deliver);
|
|
expect(gentleDirective.action, ReminderDirectiveAction.defer);
|
|
expect(
|
|
gentleDirective.reason,
|
|
ReminderDirectiveReason.deferRequiredDuringProtectedRest,
|
|
);
|
|
expect(gentleDirective.nextEligibleAt, DateTime.utc(2026, 6, 25, 10));
|
|
});
|
|
|
|
test('suppresses locked free-slot completed and unscheduled records', () {
|
|
final locked = scheduledTask(
|
|
id: 'locked',
|
|
now: now,
|
|
type: TaskType.locked,
|
|
);
|
|
final freeSlot = freeSlotTask(
|
|
id: 'rest',
|
|
start: now,
|
|
end: now.add(const Duration(minutes: 30)),
|
|
createdAt: now,
|
|
);
|
|
final completed = scheduledTask(
|
|
id: 'completed',
|
|
now: now,
|
|
status: TaskStatus.completed,
|
|
);
|
|
final unscheduled = scheduledTask(id: 'unscheduled', now: now)
|
|
.copyWith(clearSchedule: true);
|
|
|
|
expect(
|
|
service.directiveForTask(task: locked, now: now).reason,
|
|
ReminderDirectiveReason.suppressHiddenLockedTime,
|
|
);
|
|
expect(
|
|
service.directiveForTask(task: freeSlot, now: now).reason,
|
|
ReminderDirectiveReason.suppressFreeSlotRecord,
|
|
);
|
|
expect(
|
|
service.directiveForTask(task: completed, now: now).reason,
|
|
ReminderDirectiveReason.suppressInactiveStatus,
|
|
);
|
|
expect(
|
|
service.directiveForTask(task: unscheduled, now: now).reason,
|
|
ReminderDirectiveReason.suppressNoScheduledWindow,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
Task scheduledTask({
|
|
required String id,
|
|
required DateTime now,
|
|
TaskType type = TaskType.flexible,
|
|
TaskStatus status = TaskStatus.planned,
|
|
ReminderProfile? reminderOverride,
|
|
DateTime? start,
|
|
}) {
|
|
final scheduledStart = start ?? now;
|
|
return Task(
|
|
id: id,
|
|
title: 'Task',
|
|
projectId: 'home',
|
|
type: type,
|
|
status: status,
|
|
durationMinutes: 30,
|
|
scheduledStart: scheduledStart,
|
|
scheduledEnd: scheduledStart.add(const Duration(minutes: 30)),
|
|
reminderOverride: reminderOverride,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
}
|
|
|
|
Task freeSlotTask({
|
|
required String id,
|
|
required DateTime start,
|
|
required DateTime end,
|
|
required DateTime createdAt,
|
|
}) {
|
|
return const FreeSlotService().create(
|
|
id: id,
|
|
title: 'Rest',
|
|
start: start,
|
|
end: end,
|
|
createdAt: createdAt,
|
|
);
|
|
}
|