focus-flow/test/time_contracts_test.dart

381 lines
12 KiB
Dart

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<DomainValidationException>().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,
);
}
}