focus-flow/test/today_state_test.dart

559 lines
17 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('GetTodayStateQuery', () {
final day = CivilDate(2026, 6, 25);
final createdAt = DateTime.utc(2026, 6, 20, 8);
final project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'project-home',
);
test('returns an empty side-effect-free day state', () async {
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
);
final query = todayQuery(store);
final result = await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-empty',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
);
final state = result.requireValue;
expect(state.date, day);
expect(state.ownerId, 'owner-1');
expect(state.timeZoneId, 'UTC');
expect(state.timelineItems, isEmpty);
expect(state.currentItem, isNull);
expect(state.nextRequiredItem, isNull);
expect(state.nextFlexibleItem, isNull);
expect(state.pendingNotices, isEmpty);
expect(store.currentOperations, isEmpty);
expect(store.currentTaskActivities, isEmpty);
expect(store.currentProjectStatistics, isEmpty);
});
test(
'builds one sorted model for tasks, Free Slots, locked overlays, and notices',
() async {
final dayStart = DateTime.utc(2026, 6, 25);
final dayEnd = DateTime.utc(2026, 6, 25, 23, 59);
final flexible = task(
id: 'flexible',
title: 'Flexible work',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final freeSlot = task(
id: 'rest',
title: 'Rest',
type: TaskType.freeSlot,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
createdAt: createdAt,
);
final required = task(
id: 'appointment',
title: 'Appointment',
type: TaskType.inflexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 11),
end: DateTime.utc(2026, 6, 25, 11, 30),
createdAt: createdAt,
);
final surprise = task(
id: 'surprise',
title: 'Surprise done',
type: TaskType.surprise,
status: TaskStatus.completed,
start: DateTime.utc(2026, 6, 25, 12),
end: DateTime.utc(2026, 6, 25, 12, 20),
createdAt: createdAt,
completedAt: DateTime.utc(2026, 6, 25, 12, 20),
);
final visibleLocked = LockedBlock(
id: 'visible-lock',
name: 'Visible locked',
startTime: WallTime(hour: 13, minute: 0),
endTime: WallTime(hour: 14, minute: 0),
date: day,
hiddenByDefault: false,
projectId: project.id,
createdAt: createdAt,
updatedAt: createdAt,
);
final snapshot = SchedulingStateSnapshot(
id: 'rollover-notice',
capturedAt: DateTime.utc(2026, 6, 25, 7),
window: SchedulingWindow(start: dayStart, end: dayEnd),
tasks: const [],
notices: [
SchedulingNotice(
'Moved 2 unfinished tasks to today.',
type: SchedulingNoticeType.moved,
movementCode:
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
parameters: const {'count': 2},
),
],
);
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
initialTasks: [surprise, required, freeSlot, flexible],
initialLockedBlocks: [visibleLocked],
initialSchedulingSnapshots: [snapshot],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-mixed',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
))
.requireValue;
expect(state.timelineItems.map((item) => item.id), [
'flexible',
'rest',
'appointment',
'surprise',
'locked-block:visible-lock:2026-06-25',
]);
expect(state.timelineItems.first.taskStatus, TaskStatus.planned);
expect(state.timelineItems.first.quickActions, [
TimelineQuickAction.done,
TimelineQuickAction.push,
TimelineQuickAction.backlog,
TimelineQuickAction.breakUp,
]);
expect(
state.timelineItems.singleWhere((item) => item.id == 'rest').taskType,
TaskType.freeSlot,
);
expect(
state.timelineItems
.singleWhere((item) => item.id == 'rest')
.quickActions,
isEmpty,
);
expect(
state.timelineItems
.singleWhere((item) => item.id == 'surprise')
.taskStatus,
TaskStatus.completed,
);
final locked = state.timelineItems.last;
expect(locked.source, TodayTimelineItemSource.lockedOccurrence);
expect(locked.taskType, TaskType.locked);
expect(locked.projectColorToken, 'project-home');
expect(state.pendingNotices.single.movementCode,
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver);
expect(state.pendingNotices.single.parameters['count'], 2);
});
test('uses start-inclusive end-exclusive boundaries for current selection',
() async {
final ended = task(
id: 'ended',
title: 'Ended',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 10),
createdAt: createdAt,
);
final starting = task(
id: 'starting',
title: 'Starting',
type: TaskType.inflexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
createdAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
initialTasks: [ended, starting],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-boundary',
now: DateTime.utc(2026, 6, 25, 10),
),
date: day,
),
))
.requireValue;
expect(state.currentItem!.id, 'starting');
expect(state.currentItem!.id, isNot('ended'));
expect(state.nextRequiredItem!.id, 'starting');
});
test(
'builds compact projection without duplicating the current flexible task',
() async {
final current = task(
id: 'current-flex',
title: 'Current flexible',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final nextRequired = task(
id: 'next-required',
title: 'Next required',
type: TaskType.critical,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
createdAt: createdAt,
);
final laterFlexible = task(
id: 'later-flex',
title: 'Later flexible',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 11),
end: DateTime.utc(2026, 6, 25, 11, 30),
createdAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
initialTasks: [laterFlexible, nextRequired, current],
initialOwnerSettings: [
OwnerSettings(
ownerId: 'owner-1',
timeZoneId: 'UTC',
compactModeEnabled: true,
),
],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-compact',
now: DateTime.utc(2026, 6, 25, 9),
),
date: day,
),
))
.requireValue;
expect(state.currentItem!.id, 'current-flex');
expect(state.nextRequiredItem!.id, 'next-required');
expect(state.nextFlexibleItem!.id, 'later-flex');
expect(state.compactState.fullTimelineVisible, isFalse);
expect(state.compactState.compactItems.map((item) => item.id), [
'current-flex',
'next-required',
'later-flex',
]);
});
test('keeps hidden locked details absent unless reveal mode is requested',
() async {
final hidden = LockedBlock(
id: 'work-hours',
name: 'Work hours',
startTime: WallTime(hour: 9, minute: 0),
endTime: WallTime(hour: 17, minute: 0),
recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.thursday},
),
hiddenByDefault: true,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialLockedBlocks: [hidden],
);
final defaultState = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-hidden-default',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
))
.requireValue;
final revealedState = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-hidden-revealed',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
revealHiddenLockedBlocks: true,
),
))
.requireValue;
expect(defaultState.timelineItems, isEmpty);
expect(revealedState.timelineItems.single.id,
'locked-block:work-hours:2026-06-25');
expect(revealedState.timelineItems.single.hiddenByDefault, isTrue);
});
test('uses date-stable locked occurrence ids across days', () async {
final block = LockedBlock(
id: 'daily-lock',
name: 'Daily lock',
startTime: WallTime(hour: 9, minute: 0),
endTime: WallTime(hour: 10, minute: 0),
recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.thursday, LockedWeekday.friday},
),
hiddenByDefault: false,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialLockedBlocks: [block],
);
final query = todayQuery(store);
final thursday = (await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-thursday',
now: DateTime.utc(2026, 6, 25, 8),
),
date: CivilDate(2026, 6, 25),
),
))
.requireValue;
final friday = (await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-friday',
now: DateTime.utc(2026, 6, 26, 8),
),
date: CivilDate(2026, 6, 26),
),
))
.requireValue;
expect(thursday.timelineItems.single.id,
'locked-block:daily-lock:2026-06-25');
expect(
friday.timelineItems.single.id, 'locked-block:daily-lock:2026-06-26');
expect(thursday.timelineItems.single.id,
isNot(friday.timelineItems.single.id));
});
test('expands locked occurrences through explicit DST resolver context',
() async {
final dstDay = CivilDate(2026, 3, 8);
final block = LockedBlock(
id: 'dst-lock',
name: 'DST lock',
startTime: WallTime(hour: 1, minute: 30),
endTime: WallTime(hour: 3, minute: 30),
date: dstDay,
hiddenByDefault: false,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialLockedBlocks: [block],
initialOwnerSettings: [
OwnerSettings(
ownerId: 'owner-1',
timeZoneId: 'America/Los_Angeles',
),
],
);
final query = GetTodayStateQuery(
applicationStore: store,
timeZoneResolver: _LosAngelesDstFixtureResolver(),
);
final state = (await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-dst',
now: DateTime.utc(2026, 3, 8, 8),
timeZoneId: 'America/Los_Angeles',
),
date: dstDay,
),
))
.requireValue;
final item = state.timelineItems.single;
expect(state.dayStart, DateTime.utc(2026, 3, 8, 8));
expect(state.dayEnd, DateTime.utc(2026, 3, 9, 6, 59));
expect(item.id, 'locked-block:dst-lock:2026-03-08');
expect(item.start, DateTime.utc(2026, 3, 8, 9, 30));
expect(item.end, DateTime.utc(2026, 3, 8, 10, 30));
});
test('uses deterministic ordering for equal-start items', () async {
final bTask = task(
id: 'b-task',
title: 'B task',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final aTask = task(
id: 'a-task',
title: 'A task',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final lock = LockedBlock(
id: 'same-start',
name: 'Same start',
startTime: WallTime(hour: 9, minute: 0),
endTime: WallTime(hour: 9, minute: 30),
date: day,
hiddenByDefault: false,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialTasks: [bTask, aTask],
initialLockedBlocks: [lock],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-ordering',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
))
.requireValue;
expect(state.timelineItems.map((item) => item.id), [
'a-task',
'b-task',
'locked-block:same-start:2026-06-25',
]);
});
});
}
GetTodayStateQuery todayQuery(InMemoryApplicationUnitOfWork store) {
return GetTodayStateQuery(
applicationStore: store,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
);
}
ApplicationOperationContext appContext({
required String operationId,
required DateTime now,
String timeZoneId = 'UTC',
}) {
return ApplicationOperationContext.start(
operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext(
ownerId: 'owner-1',
timeZoneId: timeZoneId,
),
clock: FixedClock(now),
idGenerator: SequentialIdGenerator(prefix: 'generated'),
);
}
Task task({
required String id,
required String title,
required TaskType type,
required TaskStatus status,
required DateTime start,
required DateTime end,
required DateTime createdAt,
DateTime? completedAt,
}) {
return Task(
id: id,
title: title,
projectId: 'home',
type: type,
status: status,
durationMinutes: end.difference(start).inMinutes,
scheduledStart: start,
scheduledEnd: end,
completedAt: completedAt,
createdAt: createdAt,
updatedAt: createdAt,
);
}
class _LosAngelesDstFixtureResolver implements TimeZoneResolver {
@override
ResolvedLocalDateTime resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(),
}) {
if (timeZoneId.trim() != 'America/Los_Angeles') {
throw DomainValidationException(
code: DomainValidationCode.blankTimeZoneId,
invalidValue: timeZoneId,
name: 'timeZoneId',
message: 'Fixture resolver only supports America/Los_Angeles.',
);
}
final offset = _offsetFor(date, wallTime);
final localAsUtc = DateTime.utc(
date.year,
date.month,
date.day,
wallTime.hour,
wallTime.minute,
);
return ResolvedLocalDateTime(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
instant: localAsUtc.subtract(offset),
resolution: LocalTimeResolution.exact,
utcOffset: offset,
);
}
Duration _offsetFor(CivilDate date, WallTime wallTime) {
if (date == CivilDate(2026, 3, 8) && wallTime.hour >= 3) {
return const Duration(hours: -7);
}
return const Duration(hours: -8);
}
}