forked from eva/focus-flow
348 lines
12 KiB
Dart
348 lines
12 KiB
Dart
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('V1ApplicationManagementUseCases', () {
|
|
final now = DateTime.utc(2026, 6, 25, 12);
|
|
|
|
test('queries backlog with owner thresholds, filters, and sorting',
|
|
() async {
|
|
final fresh = backlogTask(
|
|
id: 'fresh',
|
|
createdAt: now.subtract(const Duration(days: 1)),
|
|
);
|
|
final aging = backlogTask(
|
|
id: 'aging',
|
|
createdAt: now.subtract(const Duration(days: 5)),
|
|
pushed: true,
|
|
);
|
|
final stale = backlogTask(
|
|
id: 'stale',
|
|
createdAt: now.subtract(const Duration(days: 10)),
|
|
pushed: true,
|
|
);
|
|
final planned = backlogTask(
|
|
id: 'planned',
|
|
createdAt: now.subtract(const Duration(days: 40)),
|
|
).copyWith(status: TaskStatus.planned);
|
|
final store = InMemoryApplicationUnitOfWork(
|
|
initialTasks: [fresh, aging, stale, planned],
|
|
initialOwnerSettings: [
|
|
OwnerSettings(
|
|
ownerId: 'owner-1',
|
|
timeZoneId: 'UTC',
|
|
backlogStalenessSettings: const BacklogStalenessSettings(
|
|
greenMaxAge: Duration(days: 2),
|
|
blueMaxAge: Duration(days: 6),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
final useCases = V1ApplicationManagementUseCases(
|
|
applicationStore: store,
|
|
);
|
|
|
|
final result = await useCases.getBacklog(
|
|
GetBacklogRequest(
|
|
context: appContext(operationId: 'read-backlog', now: now),
|
|
filter: BacklogFilter.pushed,
|
|
sortKey: BacklogSortKey.age,
|
|
),
|
|
);
|
|
|
|
final view = result.requireValue;
|
|
expect(view.items.map((item) => item.task.id), ['stale', 'aging']);
|
|
expect(view.items.map((item) => item.stalenessMarker), [
|
|
BacklogStalenessMarker.purple,
|
|
BacklogStalenessMarker.blue,
|
|
]);
|
|
expect(
|
|
view.items.any((item) => item.task.id == planned.id),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('keeps configured project defaults separate from learned suggestions',
|
|
() async {
|
|
final project = ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'home-blue',
|
|
defaultReward: RewardLevel.low,
|
|
defaultDifficulty: DifficultyLevel.easy,
|
|
defaultReminderProfile: ReminderProfile.gentle,
|
|
defaultDurationMinutes: 20,
|
|
);
|
|
final statistics = ProjectStatistics(
|
|
projectId: 'home',
|
|
completedTaskCount: 3,
|
|
durationMinuteCounts: const {45: 3},
|
|
rewardCounts: const {RewardLevel.high: 3},
|
|
difficultyCounts: const {DifficultyLevel.hard: 3},
|
|
reminderProfileCounts: const {ReminderProfile.persistent: 3},
|
|
);
|
|
final store = InMemoryApplicationUnitOfWork(
|
|
initialProjects: [project],
|
|
initialProjectStatistics: [statistics],
|
|
);
|
|
|
|
final result = await V1ApplicationManagementUseCases(
|
|
applicationStore: store,
|
|
).getProjectDefaults(
|
|
GetProjectDefaultsRequest(
|
|
context: appContext(operationId: 'read-projects', now: now),
|
|
),
|
|
);
|
|
|
|
final resolution = result.requireValue.projects.single;
|
|
expect(resolution.configuredDurationMinutes, 20);
|
|
expect(resolution.configuredReward, RewardLevel.low);
|
|
expect(resolution.configuredDifficulty, DifficultyLevel.easy);
|
|
expect(resolution.configuredReminderProfile, ReminderProfile.gentle);
|
|
expect(resolution.suggestions.durationMinutes!.value, 45);
|
|
expect(resolution.suggestions.reward!.value, RewardLevel.high);
|
|
expect(resolution.suggestions.difficulty!.value, DifficultyLevel.hard);
|
|
expect(
|
|
resolution.suggestions.reminderProfile!.value,
|
|
ReminderProfile.persistent,
|
|
);
|
|
});
|
|
|
|
test('archives projects without deleting task history', () async {
|
|
final task = backlogTask(id: 'task-1', projectId: 'home', createdAt: now);
|
|
final store = InMemoryApplicationUnitOfWork(
|
|
initialProjects: [
|
|
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'),
|
|
],
|
|
initialTasks: [task],
|
|
);
|
|
final useCases = V1ApplicationManagementUseCases(
|
|
applicationStore: store,
|
|
);
|
|
|
|
final archive = await useCases.archiveProject(
|
|
context: appContext(operationId: 'archive-project', now: now),
|
|
projectId: 'home',
|
|
);
|
|
final visibleProjects = await useCases.getProjectDefaults(
|
|
GetProjectDefaultsRequest(
|
|
context: appContext(operationId: 'read-active-projects', now: now),
|
|
),
|
|
);
|
|
final allProjects = await useCases.getProjectDefaults(
|
|
GetProjectDefaultsRequest(
|
|
context: appContext(operationId: 'read-all-projects', now: now),
|
|
includeArchived: true,
|
|
),
|
|
);
|
|
|
|
expect(archive.requireValue.isArchived, isTrue);
|
|
expect(visibleProjects.requireValue.projects, isEmpty);
|
|
expect(allProjects.requireValue.projects.single.project.id, 'home');
|
|
expect(store.currentTasks.single.id, task.id);
|
|
expect(store.currentTasks.single.projectId, 'home');
|
|
});
|
|
|
|
test('creates date-scoped locked overrides and archives blocks safely',
|
|
() async {
|
|
final day = CivilDate(2026, 6, 25);
|
|
final block = LockedBlock(
|
|
id: 'work',
|
|
name: 'Work',
|
|
startTime: WallTime(hour: 9, minute: 0),
|
|
endTime: WallTime(hour: 17, minute: 0),
|
|
recurrence: LockedBlockRecurrence.weekly(
|
|
weekdays: {LockedWeekday.thursday},
|
|
),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
final store = InMemoryApplicationUnitOfWork(
|
|
initialLockedBlocks: [block],
|
|
);
|
|
final useCases = V1ApplicationManagementUseCases(
|
|
applicationStore: store,
|
|
);
|
|
|
|
final replacement = await useCases.replaceOneDayLockedOccurrence(
|
|
context: appContext(operationId: 'replace-work', now: now),
|
|
lockedBlockId: 'work',
|
|
date: day,
|
|
startTime: WallTime(hour: 10, minute: 0),
|
|
endTime: WallTime(hour: 12, minute: 0),
|
|
name: 'Short work',
|
|
);
|
|
await useCases.addOneDayLockedOverride(
|
|
context: appContext(operationId: 'add-appointment', now: now),
|
|
date: day,
|
|
name: 'Appointment',
|
|
startTime: WallTime(hour: 14, minute: 0),
|
|
endTime: WallTime(hour: 15, minute: 0),
|
|
);
|
|
|
|
var expansion = expandLockedBlocksForDay(
|
|
blocks: store.currentLockedBlocks,
|
|
overrides: store.currentLockedOverrides,
|
|
date: day,
|
|
timeZoneId: 'UTC',
|
|
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
|
);
|
|
expect(replacement.requireValue.type, LockedBlockOverrideType.replace);
|
|
expect(expansion.occurrences.map((occurrence) => occurrence.name), [
|
|
'Short work',
|
|
'Appointment',
|
|
]);
|
|
|
|
await useCases.archiveLockedBlock(
|
|
context: appContext(operationId: 'archive-work', now: now),
|
|
lockedBlockId: 'work',
|
|
);
|
|
expansion = expandLockedBlocksForDay(
|
|
blocks: store.currentLockedBlocks,
|
|
overrides: store.currentLockedOverrides,
|
|
date: day,
|
|
timeZoneId: 'UTC',
|
|
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
|
);
|
|
|
|
expect(store.currentLockedBlocks.single.isArchived, isTrue);
|
|
expect(store.currentLockedOverrides, hasLength(2));
|
|
expect(expansion.occurrences.map((occurrence) => occurrence.name), [
|
|
'Appointment',
|
|
]);
|
|
});
|
|
|
|
test('updates owner settings with typed reinterpretation warnings',
|
|
() async {
|
|
final store = InMemoryApplicationUnitOfWork(
|
|
initialOwnerSettings: [
|
|
OwnerSettings(
|
|
ownerId: 'owner-1',
|
|
timeZoneId: 'UTC',
|
|
dayStart: WallTime(hour: 8, minute: 0),
|
|
dayEnd: WallTime(hour: 20, minute: 0),
|
|
),
|
|
],
|
|
);
|
|
|
|
final result = await V1ApplicationManagementUseCases(
|
|
applicationStore: store,
|
|
).updateOwnerSettings(
|
|
context: appContext(operationId: 'settings', now: now),
|
|
update: OwnerSettingsUpdate(
|
|
timeZoneId: 'America/Los_Angeles',
|
|
dayStart: WallTime(hour: 7, minute: 0),
|
|
compactModeEnabled: true,
|
|
backlogStalenessSettings: const BacklogStalenessSettings(
|
|
greenMaxAge: Duration(days: 3),
|
|
blueMaxAge: Duration(days: 8),
|
|
),
|
|
),
|
|
);
|
|
|
|
final update = result.requireValue;
|
|
expect(update.settings.timeZoneId, 'America/Los_Angeles');
|
|
expect(update.settings.dayStart, WallTime(hour: 7, minute: 0));
|
|
expect(update.settings.compactModeEnabled, isTrue);
|
|
expect(update.settings.backlogStalenessSettings.blueMaxAge.inDays, 8);
|
|
expect(update.warnings, [
|
|
OwnerSettingsWarningCode.timeZoneChanged,
|
|
OwnerSettingsWarningCode.planningWindowChanged,
|
|
]);
|
|
});
|
|
|
|
test('acknowledges one-time notices and hides them from Today state',
|
|
() async {
|
|
final day = CivilDate(2026, 6, 25);
|
|
final snapshot = SchedulingStateSnapshot(
|
|
id: 'rollover',
|
|
capturedAt: now,
|
|
window: SchedulingWindow(
|
|
start: DateTime.utc(2026, 6, 25),
|
|
end: DateTime.utc(2026, 6, 25, 23, 59),
|
|
),
|
|
tasks: const [],
|
|
notices: [
|
|
SchedulingNotice(
|
|
'Moved 2 unfinished tasks to today.',
|
|
type: SchedulingNoticeType.moved,
|
|
movementCode:
|
|
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
|
|
parameters: const {'count': 2},
|
|
),
|
|
],
|
|
);
|
|
final store = InMemoryApplicationUnitOfWork(
|
|
initialSchedulingSnapshots: [snapshot],
|
|
);
|
|
final todayQuery = GetTodayStateQuery(
|
|
applicationStore: store,
|
|
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
|
);
|
|
final useCases = V1ApplicationManagementUseCases(
|
|
applicationStore: store,
|
|
);
|
|
|
|
final before = (await todayQuery.execute(
|
|
GetTodayStateRequest(
|
|
context: appContext(operationId: 'today-before', now: now),
|
|
date: day,
|
|
),
|
|
))
|
|
.requireValue;
|
|
final noticeId = before.pendingNotices.single.noticeId;
|
|
|
|
final acknowledged = await useCases.acknowledgeNotice(
|
|
context: appContext(operationId: 'ack-notice', now: now),
|
|
noticeId: noticeId,
|
|
);
|
|
final after = (await todayQuery.execute(
|
|
GetTodayStateRequest(
|
|
context: appContext(operationId: 'today-after', now: now),
|
|
date: day,
|
|
),
|
|
))
|
|
.requireValue;
|
|
|
|
expect(
|
|
noticeId, schedulingNoticeId(snapshotId: 'rollover', noticeIndex: 0));
|
|
expect(acknowledged.requireValue.alreadyAcknowledged, isFalse);
|
|
expect(store.currentNoticeAcknowledgements.single.noticeId, noticeId);
|
|
expect(after.pendingNotices, isEmpty);
|
|
});
|
|
});
|
|
}
|
|
|
|
ApplicationOperationContext appContext({
|
|
required String operationId,
|
|
required DateTime now,
|
|
}) {
|
|
return ApplicationOperationContext.start(
|
|
operationId: operationId,
|
|
ownerTimeZone: OwnerTimeZoneContext(ownerId: 'owner-1', timeZoneId: 'UTC'),
|
|
clock: FixedClock(now),
|
|
idGenerator: SequentialIdGenerator(prefix: operationId),
|
|
);
|
|
}
|
|
|
|
Task backlogTask({
|
|
required String id,
|
|
required DateTime createdAt,
|
|
String projectId = 'inbox',
|
|
bool pushed = false,
|
|
}) {
|
|
return Task(
|
|
id: id,
|
|
title: 'Task $id',
|
|
projectId: projectId,
|
|
type: TaskType.flexible,
|
|
status: TaskStatus.backlog,
|
|
priority: PriorityLevel.medium,
|
|
durationMinutes: 30,
|
|
createdAt: createdAt,
|
|
updatedAt: createdAt,
|
|
stats: pushed
|
|
? const TaskStatistics(manuallyPushedCount: 1)
|
|
: const TaskStatistics(),
|
|
);
|
|
}
|