import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { group('Civil date and wall-time contracts', () { test('civil dates preserve date-only boundaries', () { expect(CivilDate(2024, 2, 29).addDays(1), CivilDate(2024, 3, 1)); expect(CivilDate(2026, 12, 31).addDays(1), CivilDate(2027, 1, 1)); expect(CivilDate.fromIsoString('2026-01-02'), CivilDate(2026, 1, 2)); expect(CivilDate(2026, 6, 24).toIsoString(), '2026-06-24'); }); test('civil date and wall-time parsing reject loose timestamp shapes', () { expectValidationCode( () => CivilDate.fromIsoString('2026-2-29'), DomainValidationCode.invalidCivilDate, ); expectValidationCode( () => CivilDate.fromIsoString('2026-02-29'), DomainValidationCode.invalidCivilDate, ); expectValidationCode( () => WallTime.fromIsoString('9:05'), DomainValidationCode.invalidClockTime, ); expectValidationCode( () => WallTime.fromIsoString('24:00'), DomainValidationCode.invalidClockTime, ); }); test('wall times preserve midnight and end-of-day values', () { expect(WallTime(hour: 0, minute: 0).toIsoString(), '00:00'); expect(WallTime(hour: 23, minute: 59).toIsoString(), '23:59'); expect(WallTime.fromIsoString('09:05').minutesSinceMidnight, 545); }); test('overnight locked rules are rejected instead of normalized', () { final now = DateTime.utc(2026, 6, 24, 12); expectValidationCode( () => LockedBlock( id: 'overnight', name: 'Overnight work', startTime: WallTime(hour: 23, minute: 0), endTime: WallTime(hour: 1, minute: 0), date: CivilDate(2026, 6, 24), createdAt: now, updatedAt: now, ), DomainValidationCode.invalidLockedBlock, ); }); }); group('Clock and id boundaries', () { test('scheduling engine uses injected clock when updatedAt is omitted', () { final stamp = DateTime.utc(2026, 6, 24, 18); final engine = SchedulingEngine(clock: FixedClock(stamp)); final task = Task.quickCapture( id: 'task', title: 'Inbox item', createdAt: DateTime.utc(2026, 6, 24, 9), ); final moved = engine.moveToBacklog(task); expect(moved.updatedAt, stamp); }); test('quick capture convenience wrapper uses injected id and clock', () { final stamp = DateTime.utc(2026, 6, 24, 18); final service = QuickCaptureService( clock: FixedClock(stamp), idGenerator: SequentialIdGenerator(prefix: 'capture', start: 7), ); final result = service.captureNew(title: ' Pay bill '); expect(result.status, QuickCaptureStatus.addedToBacklog); expect(result.task.id, 'capture-7'); expect(result.task.title, 'Pay bill'); expect(result.task.createdAt, stamp); expect(result.task.updatedAt, stamp); }); test('surprise log convenience wrapper uses injected id and clock', () { final stamp = DateTime.utc(2026, 6, 24, 18); final service = SurpriseTaskLogService( clock: FixedClock(stamp), idGenerator: SequentialIdGenerator(prefix: 'surprise'), ); final input = SchedulingInput( tasks: const [], window: SchedulingWindow( start: DateTime.utc(2026, 6, 24, 8), end: DateTime.utc(2026, 6, 24, 20), ), ); final result = service.logNew( title: 'Unexpected call', input: input, ); expect(result.surpriseTask.id, 'surprise-1'); expect(result.surpriseTask.createdAt, stamp); expect(result.surpriseTask.updatedAt, stamp); expect(result.surpriseTask.status, TaskStatus.completed); }); }); group('Time-zone resolution boundary', () { test('fixed offset resolver converts local wall time to UTC instant', () { const resolver = FixedOffsetTimeZoneResolver( offset: Duration(hours: 5, minutes: 30), ); final resolved = resolver.resolve( date: CivilDate(2026, 6, 24), wallTime: WallTime(hour: 9, minute: 0), timeZoneId: 'Asia/Kolkata', ); expect(resolved.instant, DateTime.utc(2026, 6, 24, 3, 30)); expect(resolved.resolution, LocalTimeResolution.exact); expect(resolved.utcOffset, const Duration(hours: 5, minutes: 30)); }); test('blank time-zone ids are rejected', () { const resolver = FixedOffsetTimeZoneResolver.utc(); expectValidationCode( () => resolver.resolve( date: CivilDate(2026, 6, 24), wallTime: WallTime(hour: 9, minute: 0), timeZoneId: ' ', ), DomainValidationCode.blankTimeZoneId, ); }); test('DST gap policy shifts nonexistent local time forward by default', () { final resolver = _LosAngelesDstFixtureResolver(); final block = LockedBlock( id: 'sleep-boundary', name: 'Sleep boundary', startTime: WallTime(hour: 2, minute: 30), endTime: WallTime(hour: 3, minute: 30), recurrence: LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.sunday}, ), createdAt: DateTime.utc(2026, 3, 1), updatedAt: DateTime.utc(2026, 3, 1), ); final expansion = expandLockedBlocksForDay( blocks: [block], overrides: const [], date: CivilDate(2026, 3, 8), timeZoneId: 'America/Los_Angeles', timeZoneResolver: resolver, ); final directResolution = resolver.resolve( date: CivilDate(2026, 3, 8), wallTime: WallTime(hour: 2, minute: 30), timeZoneId: 'America/Los_Angeles', ); expect(directResolution.resolution, LocalTimeResolution.shiftedForward); expect(expansion.occurrences.single.interval.start, DateTime.utc(2026, 3, 8, 10)); expect(expansion.occurrences.single.interval.end, DateTime.utc(2026, 3, 8, 10, 30)); }); test('DST repeated local time uses earlier instant by default', () { final resolver = _LosAngelesDstFixtureResolver(); final block = LockedBlock( id: 'quiet-hour', name: 'Quiet hour', startTime: WallTime(hour: 1, minute: 30), endTime: WallTime(hour: 2, minute: 30), date: CivilDate(2026, 11, 1), createdAt: DateTime.utc(2026, 11, 1), updatedAt: DateTime.utc(2026, 11, 1), ); final expansion = expandLockedBlocksForDay( blocks: [block], overrides: const [], date: CivilDate(2026, 11, 1), timeZoneId: 'America/Los_Angeles', timeZoneResolver: resolver, ); expect(expansion.occurrences.single.interval.start, DateTime.utc(2026, 11, 1, 8, 30)); expect(expansion.occurrences.single.interval.end, DateTime.utc(2026, 11, 1, 10, 30)); }); test('DST repeated local time can choose the later instant', () { final resolver = _LosAngelesDstFixtureResolver(); final block = LockedBlock( id: 'quiet-hour', name: 'Quiet hour', startTime: WallTime(hour: 1, minute: 30), endTime: WallTime(hour: 2, minute: 30), date: CivilDate(2026, 11, 1), createdAt: DateTime.utc(2026, 11, 1), updatedAt: DateTime.utc(2026, 11, 1), ); final expansion = expandLockedBlocksForDay( blocks: [block], overrides: const [], date: CivilDate(2026, 11, 1), timeZoneId: 'America/Los_Angeles', timeZoneResolver: resolver, resolutionOptions: const TimeZoneResolutionOptions( repeatedLocalTimePolicy: RepeatedLocalTimePolicy.later, ), ); expect(expansion.occurrences.single.interval.start, DateTime.utc(2026, 11, 1, 9, 30)); expect(expansion.occurrences.single.interval.end, DateTime.utc(2026, 11, 1, 10, 30)); }); test('DST reject policies return typed validation codes', () { final resolver = _LosAngelesDstFixtureResolver(); expectValidationCode( () => resolver.resolve( date: CivilDate(2026, 3, 8), wallTime: WallTime(hour: 2, minute: 30), timeZoneId: 'America/Los_Angeles', options: const TimeZoneResolutionOptions( nonexistentLocalTimePolicy: NonexistentLocalTimePolicy.reject, ), ), DomainValidationCode.nonexistentLocalTime, ); expectValidationCode( () => resolver.resolve( date: CivilDate(2026, 11, 1), wallTime: WallTime(hour: 1, minute: 30), timeZoneId: 'America/Los_Angeles', options: const TimeZoneResolutionOptions( repeatedLocalTimePolicy: RepeatedLocalTimePolicy.reject, ), ), DomainValidationCode.ambiguousLocalTime, ); }); }); } void expectValidationCode( Object? Function() body, DomainValidationCode expectedCode, ) { expect( body, throwsA( isA().having( (error) => error.code, 'code', expectedCode, ), ), ); } 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.', ); } if (date == CivilDate(2026, 3, 8) && wallTime.hour == 2) { if (options.nonexistentLocalTimePolicy == NonexistentLocalTimePolicy.reject) { throw DomainValidationException( code: DomainValidationCode.nonexistentLocalTime, invalidValue: {'date': date, 'wallTime': wallTime}, name: 'wallTime', message: 'Local time does not exist in this time-zone.', ); } return _resolved( date: date, wallTime: WallTime(hour: 3, minute: 0), timeZoneId: timeZoneId, offset: const Duration(hours: -7), resolution: LocalTimeResolution.shiftedForward, ); } if (date == CivilDate(2026, 11, 1) && wallTime.hour == 1) { if (options.repeatedLocalTimePolicy == RepeatedLocalTimePolicy.reject) { throw DomainValidationException( code: DomainValidationCode.ambiguousLocalTime, invalidValue: {'date': date, 'wallTime': wallTime}, name: 'wallTime', message: 'Local time is repeated in this time-zone.', ); } final useLater = options.repeatedLocalTimePolicy == RepeatedLocalTimePolicy.later; return _resolved( date: date, wallTime: wallTime, timeZoneId: timeZoneId, offset: Duration(hours: useLater ? -8 : -7), resolution: useLater ? LocalTimeResolution.repeatedLater : LocalTimeResolution.repeatedEarlier, ); } final offset = _offsetFor(date, wallTime); return _resolved( date: date, wallTime: wallTime, timeZoneId: timeZoneId, offset: offset, resolution: LocalTimeResolution.exact, ); } Duration _offsetFor(CivilDate date, WallTime wallTime) { if (date == CivilDate(2026, 3, 8) && wallTime.hour >= 3) { return const Duration(hours: -7); } if (date == CivilDate(2026, 11, 1) && wallTime.hour >= 2) { return const Duration(hours: -8); } return const Duration(hours: -8); } ResolvedLocalDateTime _resolved({ required CivilDate date, required WallTime wallTime, required String timeZoneId, required Duration offset, required LocalTimeResolution resolution, }) { 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: resolution, utcOffset: offset, ); } }