forked from eva/focus-flow
381 lines
12 KiB
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,
|
|
);
|
|
}
|
|
}
|