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); } }