forked from eva/focus-flow
559 lines
17 KiB
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);
|
|
}
|
|
}
|