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