diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md b/Codex Documentation/Current Software Plan/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md index 6cfcc7a..1b91c2b 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md @@ -149,6 +149,32 @@ Acceptance criteria: Recommended Codex level: high +Status: Complete on 2026-06-24. + +Implementation outputs: + +- Added typed domain validation codes and `DomainValidationException`. +- Enforced non-blank task/project/locked-time identifiers and labels at domain + model boundaries. +- Enforced positive optional durations and valid scheduled, actual, + `TimeInterval`, `SchedulingWindow`, locked block, recurrence, and override + intervals. +- Added explicit nullable clear semantics for task priority, duration, schedule + interval, actual interval, parent ownership, reminder override, and project + default duration. +- Added task `actualStart`, `actualEnd`, and `reminderOverride` fields with + document mapping support. +- Rejected self-parenting and preserved direct-child-only ownership. +- Defensively copied public domain/read-model collections into unmodifiable + snapshots. + +Test outputs: + +- Added `test/domain_invariants_test.dart` for validation codes, clear paths, + immutable collection boundaries, and locked-time invariants. +- Updated persistence and scheduler tests for the new document fields and + immutable result snapshots. + Tasks: - Enforce non-blank stable IDs, titles, and project IDs at model boundaries. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index a105177..e2ac672 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -33,7 +33,7 @@ changes from accidental API drift. | File | Enums | |---|---| -| `src/models.dart` | `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` | +| `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` | | `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` | | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | | `src/quick_capture.dart` | `QuickCaptureStatus` | @@ -45,7 +45,7 @@ changes from accidental API drift. | File | Public API | |---|---| -| `src/models.dart` | `Task`, `ProjectProfile`, `TimeInterval` | +| `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` | | `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` | | `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` | | `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` | @@ -83,7 +83,8 @@ Current notable model fields: - `Task`: `id`, `title`, `projectId`, `type`, `status`, `priority`, `reward`, `difficulty`, `durationMinutes`, `scheduledStart`, `scheduledEnd`, - `parentTaskId`, `backlogTags`, `createdAt`, `updatedAt`, `stats` + `actualStart`, `actualEnd`, `parentTaskId`, `backlogTags`, + `reminderOverride`, `createdAt`, `updatedAt`, `stats` - `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/ difficulty/reminder profile/duration fields - `TimeInterval`: `start`, `end`, optional `label` @@ -94,12 +95,24 @@ Known baseline gaps to preserve as explicit later work: - `TaskStatus` does not include `pushed` or `skipped`; Chunk 11.2 resolves the status/event distinction. -- `Task` does not yet have explicit completion timestamp, actual interval, - backlog-entered timestamp, or task-level reminder override fields. -- Nullable clearing is limited; broad explicit patch semantics are Chunk 11.3. +- `Task` does not yet have explicit completion timestamp or backlog-entered + timestamp fields. - Time is currently `DateTime`-based; civil-date, wall-time, clock, ID, and timezone contracts are Chunk 11.4. +## Intentional API Changes After Baseline + +Chunk 11.3 intentionally changed the public API: + +- Added `DomainValidationCode` and `DomainValidationException`. +- Added `Task.actualStart`, `Task.actualEnd`, and `Task.reminderOverride`. +- Added explicit `copyWith` clear flags for nullable task fields. +- Added `ProjectProfile.copyWith(clearDefaultDuration: true)`. +- Changed several public value constructors from `const` to runtime-validating + constructors. +- Public collection fields now expose unmodifiable snapshots instead of caller + mutable collections. + ## Repository contracts The current repository surface is pure Dart and in-memory only: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index c213561..14e6329 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -59,7 +59,7 @@ Status values: | MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. | | MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. | | MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, timeline tokens | `test/timeline_state_test.dart` | incomplete | Scheduling protection and reminder directives remain in Blocks 12 and 13. | -| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ReminderProfile` | `test/scheduling_engine_test.dart` | incomplete | Learned/configured precedence and task overrides remain in Chunks 11.2 and Block 13. | +| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart` | incomplete | Task override storage exists; effective resolver, learned suggestions, and reminder directives remain in Block 13. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. | | MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | incomplete | Canonical transition/activity layer remains in Block 13. | diff --git a/lib/src/backlog.dart b/lib/src/backlog.dart index 6dcae62..a917f8e 100644 --- a/lib/src/backlog.dart +++ b/lib/src/backlog.dart @@ -120,12 +120,12 @@ class BacklogStalenessSettings { /// That keeps backlog display logic out of widgets and avoids duplicating the /// same filtering rules in multiple screens. class BacklogView { - const BacklogView({ - required this.tasks, + BacklogView({ + required List tasks, required this.now, this.staleAfter = const Duration(days: 31), this.stalenessSettings = const BacklogStalenessSettings(), - }); + }) : tasks = List.unmodifiable(tasks); /// Master task list supplied by the caller. Only `status == backlog` items are /// shown by this view. diff --git a/lib/src/child_tasks.dart b/lib/src/child_tasks.dart index 9af68d3..bcf5c29 100644 --- a/lib/src/child_tasks.dart +++ b/lib/src/child_tasks.dart @@ -83,9 +83,9 @@ class ChildTaskEntry { /// as "which tasks belong to this parent?" while leaving task placement and /// completion rules to other domain services. class ChildTaskView { - const ChildTaskView({ - required this.tasks, - }); + ChildTaskView({ + required List tasks, + }) : tasks = List.unmodifiable(tasks); /// Source task list supplied by the caller. final List tasks; @@ -165,15 +165,18 @@ class ChildTaskView { /// Result from child/parent completion propagation. class ChildTaskCompletionResult { - const ChildTaskCompletionResult({ - required this.tasks, - required this.changedTaskIds, + ChildTaskCompletionResult({ + required List tasks, + required List changedTaskIds, this.completedChildId, this.completedParentId, - this.forceCompletedChildIds = const [], + List forceCompletedChildIds = const [], this.autoCompletedParent = false, this.canCompleteParentExplicitly = false, - }); + }) : tasks = List.unmodifiable(tasks), + changedTaskIds = List.unmodifiable(changedTaskIds), + forceCompletedChildIds = + List.unmodifiable(forceCompletedChildIds); /// Replacement task list after the completion action. final List tasks; diff --git a/lib/src/document_mapping.dart b/lib/src/document_mapping.dart index ab37b27..20c8609 100644 --- a/lib/src/document_mapping.dart +++ b/lib/src/document_mapping.dart @@ -35,6 +35,12 @@ abstract final class PersistenceEnumMapping { return _decodeEnum(DifficultyLevel.values, value, 'DifficultyLevel'); } + static ReminderProfile? decodeNullableReminderProfile(String? value) { + return value == null + ? null + : _decodeEnum(ReminderProfile.values, value, 'ReminderProfile'); + } + static BacklogTag decodeBacklogTag(String value) { return _decodeEnum(BacklogTag.values, value, 'BacklogTag'); } @@ -81,9 +87,13 @@ abstract final class TaskDocumentMapping { TaskDocumentFields.durationMinutes: task.durationMinutes, TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), + TaskDocumentFields.actualStart: _dateTimeToStored(task.actualStart), + TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd), TaskDocumentFields.parentTaskId: task.parentTaskId, TaskDocumentFields.backlogTags: task.backlogTags.map((tag) => tag.persistenceName).toList(), + TaskDocumentFields.reminderOverride: + PersistenceEnumMapping.encodeNullable(task.reminderOverride), TaskDocumentFields.createdAt: PersistenceDateTimeConvention.toStoredString(task.createdAt), TaskDocumentFields.updatedAt: @@ -119,8 +129,13 @@ abstract final class TaskDocumentMapping { _nullableDateTime(document, TaskDocumentFields.scheduledStart), scheduledEnd: _nullableDateTime(document, TaskDocumentFields.scheduledEnd), + actualStart: _nullableDateTime(document, TaskDocumentFields.actualStart), + actualEnd: _nullableDateTime(document, TaskDocumentFields.actualEnd), parentTaskId: _nullableString(document, TaskDocumentFields.parentTaskId), backlogTags: _backlogTagsFromDocument(document), + reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( + _nullableString(document, TaskDocumentFields.reminderOverride), + ), createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), stats: TaskStatisticsDocumentMapping.fromDocument( diff --git a/lib/src/locked_time.dart b/lib/src/locked_time.dart index a035637..2e587ef 100644 --- a/lib/src/locked_time.dart +++ b/lib/src/locked_time.dart @@ -33,11 +33,19 @@ enum LockedWeekday { /// than as one specific timestamp. [ClockTime] stores just the hour/minute part /// and can later be projected onto a concrete date with [onDate]. class ClockTime { - const ClockTime({ + ClockTime({ required this.hour, required this.minute, - }) : assert(hour >= 0 && hour < 24, 'hour must be 0-23'), - assert(minute >= 0 && minute < 60, 'minute must be 0-59'); + }) { + if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) { + throw DomainValidationException( + code: DomainValidationCode.invalidClockTime, + invalidValue: {'hour': hour, 'minute': minute}, + name: 'ClockTime', + message: 'Clock time must use hour 0-23 and minute 0-59.', + ); + } + } /// 24-hour clock hour, 0 through 23. final int hour; @@ -62,9 +70,18 @@ class ClockTime { /// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence /// rules can be added later without changing the rest of the locked-time model. class LockedBlockRecurrence { - const LockedBlockRecurrence.weekly({ - required this.weekdays, - }); + LockedBlockRecurrence.weekly({ + required Set weekdays, + }) : weekdays = Set.unmodifiable(weekdays) { + if (weekdays.isEmpty) { + throw DomainValidationException( + code: DomainValidationCode.emptyRecurrence, + invalidValue: weekdays, + name: 'weekdays', + message: 'Recurring locked blocks need at least one weekday.', + ); + } + } /// Weekdays when this recurrence should produce an occurrence. final Set weekdays; @@ -87,9 +104,9 @@ class LockedBlockRecurrence { /// - one-off, with [date] set and [recurrence] null; or /// - recurring, with [recurrence] set and [date] usually null. class LockedBlock { - const LockedBlock({ - required this.id, - required this.name, + LockedBlock({ + required String id, + required String name, required this.startTime, required this.endTime, required this.createdAt, @@ -98,10 +115,27 @@ class LockedBlock { this.recurrence, this.hiddenByDefault = true, this.projectId, - }) : assert( - recurrence != null || date != null, - 'date is required for one-off locked blocks', - ); + }) : id = + _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), + name = _requiredLockedString( + name, 'name', DomainValidationCode.blankName) { + if (recurrence == null && date == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedBlock, + invalidValue: null, + name: 'date', + message: 'One-off locked blocks need a date.', + ); + } + if (!_clockTimeIsBefore(startTime, endTime)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedBlock, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Locked block end time must be after start time.', + ); + } + } /// Stable id for persistence and one-day overrides. final String id; @@ -238,19 +272,38 @@ enum LockedBlockOverrideType { /// - [replace] references a base block and swaps its details for one day. /// - [add] creates an extra locked occurrence that has no base block. class LockedBlockOverride { - const LockedBlockOverride({ - required this.id, + LockedBlockOverride({ + required String id, required this.date, required this.type, required this.createdAt, required this.updatedAt, - this.lockedBlockId, - this.name, + String? lockedBlockId, + String? name, this.startTime, this.endTime, this.hiddenByDefault = true, this.projectId, - }); + }) : id = + _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), + lockedBlockId = _nullableLockedString( + lockedBlockId, + 'lockedBlockId', + DomainValidationCode.blankStableId, + ), + name = _nullableLockedString( + name, + 'name', + DomainValidationCode.blankName, + ) { + _validateOverrideShape( + type: type, + lockedBlockId: this.lockedBlockId, + name: this.name, + startTime: startTime, + endTime: endTime, + ); + } /// Removes a recurring occurrence for one date. factory LockedBlockOverride.remove({ @@ -715,3 +768,123 @@ bool _sameDate(DateTime first, DateTime second) { first.month == second.month && first.day == second.day; } + +String _requiredLockedString( + String value, + String name, + DomainValidationCode code, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: '$name cannot be blank.', + ); + } + return trimmed; +} + +String? _nullableLockedString( + String? value, + String name, + DomainValidationCode code, +) { + if (value == null) { + return null; + } + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: '$name cannot be blank.', + ); + } + return trimmed; +} + +bool _clockTimeIsBefore(ClockTime start, ClockTime end) { + if (start.hour != end.hour) { + return start.hour < end.hour; + } + return start.minute < end.minute; +} + +void _validateOverrideShape({ + required LockedBlockOverrideType type, + required String? lockedBlockId, + required String? name, + required ClockTime? startTime, + required ClockTime? endTime, +}) { + switch (type) { + case LockedBlockOverrideType.remove: + if (lockedBlockId == null || + name != null || + startTime != null || + endTime != null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'LockedBlockOverride.remove', + message: 'Remove overrides only reference a locked block and date.', + ); + } + case LockedBlockOverrideType.replace: + if (lockedBlockId == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'lockedBlockId', + message: 'Replace overrides must reference a locked block.', + ); + } + _validateOverrideInterval(startTime: startTime, endTime: endTime); + case LockedBlockOverrideType.add: + if (lockedBlockId != null || name == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'LockedBlockOverride.add', + message: + 'Add overrides need a name and cannot reference a base block.', + ); + } + if (startTime == null || endTime == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'startTime/endTime', + message: 'Add overrides need both start and end time.', + ); + } + _validateOverrideInterval(startTime: startTime, endTime: endTime); + } +} + +void _validateOverrideInterval({ + required ClockTime? startTime, + required ClockTime? endTime, +}) { + if ((startTime == null) != (endTime == null)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Override start and end time must both be present or absent.', + ); + } + if (startTime != null && + endTime != null && + !_clockTimeIsBefore(startTime, endTime)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Override end time must be after start time.', + ); + } +} diff --git a/lib/src/models.dart b/lib/src/models.dart index c931cac..f2663b7 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -13,6 +13,45 @@ import 'task_statistics.dart'; +/// Stable validation failure categories for domain model boundaries. +/// +/// Callers should branch on these codes instead of parsing exception text. The +/// explanatory messages may change, but these enum values are part of the V1 +/// backend contract. +enum DomainValidationCode { + blankStableId, + blankTitle, + blankProjectId, + blankName, + blankColorKey, + blankParentTaskId, + selfParent, + nonPositiveDuration, + partialScheduledInterval, + invalidScheduledInterval, + partialActualInterval, + invalidActualInterval, + invalidTimeInterval, + invalidSchedulingWindow, + invalidClockTime, + emptyRecurrence, + invalidLockedBlock, + invalidLockedOverride, +} + +/// Typed validation error thrown by domain model constructors. +class DomainValidationException extends ArgumentError { + DomainValidationException({ + required this.code, + required Object? invalidValue, + required String name, + required String message, + }) : super.value(invalidValue, name, message); + + /// Stable category for application and persistence layers. + final DomainValidationCode code; +} + /// Scheduling behavior category. /// /// This enum is one of the central concepts in the planner. The type answers @@ -199,10 +238,10 @@ enum BacklogTag { /// This is still a starter V1 model, so behavior changes should be explicit and /// backed by tests as the product rules settle. class Task { - const Task({ - required this.id, - required this.title, - required this.projectId, + Task({ + required String id, + required String title, + required String projectId, required this.type, required this.status, required this.createdAt, @@ -210,13 +249,71 @@ class Task { this.priority, this.reward = RewardLevel.notSet, this.difficulty = DifficultyLevel.notSet, - this.durationMinutes, + int? durationMinutes, this.scheduledStart, this.scheduledEnd, - this.parentTaskId, - this.backlogTags = const {}, + this.actualStart, + this.actualEnd, + String? parentTaskId, + Set backlogTags = const {}, + this.reminderOverride, this.stats = const TaskStatistics(), - }); + }) : id = _requiredTrimmed( + id, + 'id', + DomainValidationCode.blankStableId, + 'Stable id is required.', + ), + title = _requiredTrimmed( + title, + 'title', + DomainValidationCode.blankTitle, + 'Title is required.', + ), + projectId = _requiredTrimmed( + projectId, + 'projectId', + DomainValidationCode.blankProjectId, + 'Project id is required.', + ), + durationMinutes = durationMinutes, + parentTaskId = _nullableTrimmed( + parentTaskId, + 'parentTaskId', + DomainValidationCode.blankParentTaskId, + 'Parent task id cannot be blank.', + ), + backlogTags = Set.unmodifiable(backlogTags) { + _validatePositiveDuration(durationMinutes, 'durationMinutes'); + _validateOptionalInterval( + start: scheduledStart, + end: scheduledEnd, + partialCode: DomainValidationCode.partialScheduledInterval, + invalidCode: DomainValidationCode.invalidScheduledInterval, + name: 'scheduledStart/scheduledEnd', + partialMessage: + 'Scheduled start and scheduled end must both be present or absent.', + invalidMessage: 'Scheduled end must be after scheduled start.', + ); + _validateOptionalInterval( + start: actualStart, + end: actualEnd, + partialCode: DomainValidationCode.partialActualInterval, + invalidCode: DomainValidationCode.invalidActualInterval, + name: 'actualStart/actualEnd', + partialMessage: + 'Actual start and actual end must both be present or absent.', + invalidMessage: 'Actual end must be after actual start.', + ); + if (this.parentTaskId == this.id) { + throw DomainValidationException( + code: DomainValidationCode.selfParent, + invalidValue: parentTaskId, + name: 'parentTaskId', + message: 'A task cannot be its own parent.', + ); + } + } /// Create a minimal captured task without requiring planning details. /// @@ -240,10 +337,6 @@ class Task { Set backlogTags = const {}, DateTime? updatedAt, }) { - if (title.trim().isEmpty) { - throw ArgumentError.value(title, 'title', 'Title is required.'); - } - return Task( id: id, title: title.trim(), @@ -294,12 +387,21 @@ class Task { /// Exclusive scheduled end time. Null means the task is not currently placed. final DateTime? scheduledEnd; + /// Actual work start time, when known after the fact. + final DateTime? actualStart; + + /// Actual work end time, when known after the fact. + final DateTime? actualEnd; + /// Parent task id when this task is a child/subtask. Null means top-level task. final String? parentTaskId; /// Backlog-specific flags, such as wishlist/someday behavior. final Set backlogTags; + /// Optional task-level reminder override. + final ReminderProfile? reminderOverride; + /// Creation timestamp used for age/staleness sorting. final DateTime createdAt; @@ -334,6 +436,9 @@ class Task { /// Without it, passing null would be ambiguous: it could mean "do not change" /// or "clear this value." When [clearSchedule] is true, both schedule fields /// are removed even if [scheduledStart] or [scheduledEnd] are omitted. + /// + /// The other `clear*` flags provide the same explicit semantics for nullable + /// fields that the product can remove. Task copyWith({ String? id, String? title, @@ -346,12 +451,20 @@ class Task { int? durationMinutes, DateTime? scheduledStart, DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, String? parentTaskId, Set? backlogTags, + ReminderProfile? reminderOverride, DateTime? createdAt, DateTime? updatedAt, TaskStatistics? stats, + bool clearPriority = false, + bool clearDuration = false, bool clearSchedule = false, + bool clearActualInterval = false, + bool clearParentTask = false, + bool clearReminderOverride = false, }) { return Task( id: id ?? this.id, @@ -359,15 +472,23 @@ class Task { projectId: projectId ?? this.projectId, type: type ?? this.type, status: status ?? this.status, - priority: priority ?? this.priority, + priority: clearPriority ? null : (priority ?? this.priority), reward: reward ?? this.reward, difficulty: difficulty ?? this.difficulty, - durationMinutes: durationMinutes ?? this.durationMinutes, + durationMinutes: + clearDuration ? null : (durationMinutes ?? this.durationMinutes), scheduledStart: clearSchedule ? null : (scheduledStart ?? this.scheduledStart), scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd), - parentTaskId: parentTaskId ?? this.parentTaskId, + actualStart: + clearActualInterval ? null : (actualStart ?? this.actualStart), + actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), + parentTaskId: + clearParentTask ? null : (parentTaskId ?? this.parentTaskId), backlogTags: backlogTags ?? this.backlogTags, + reminderOverride: clearReminderOverride + ? null + : (reminderOverride ?? this.reminderOverride), createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, stats: stats ?? this.stats, @@ -385,16 +506,39 @@ class Task { /// project defaults separate prevents every scheduling function from needing to /// know project configuration details. class ProjectProfile { - const ProjectProfile({ - required this.id, - required this.name, - required this.colorKey, + ProjectProfile({ + required String id, + required String name, + required String colorKey, this.defaultPriority = PriorityLevel.medium, this.defaultReward = RewardLevel.notSet, this.defaultDifficulty = DifficultyLevel.notSet, this.defaultReminderProfile = ReminderProfile.gentle, - this.defaultDurationMinutes, - }); + int? defaultDurationMinutes, + }) : id = _requiredTrimmed( + id, + 'id', + DomainValidationCode.blankStableId, + 'Stable id is required.', + ), + name = _requiredTrimmed( + name, + 'name', + DomainValidationCode.blankName, + 'Project name is required.', + ), + colorKey = _requiredTrimmed( + colorKey, + 'colorKey', + DomainValidationCode.blankColorKey, + 'Project color key is required.', + ), + defaultDurationMinutes = defaultDurationMinutes { + _validatePositiveDuration( + defaultDurationMinutes, + 'defaultDurationMinutes', + ); + } /// Stable project id stored on tasks. final String id; @@ -437,18 +581,16 @@ class ProjectProfile { int? durationMinutes, DateTime? scheduledStart, DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, String? parentTaskId, Set backlogTags = const {}, + ReminderProfile? reminderOverride, DateTime? updatedAt, }) { - final trimmedTitle = title.trim(); - if (trimmedTitle.isEmpty) { - throw ArgumentError.value(title, 'title', 'Title is required.'); - } - return Task( id: id, - title: trimmedTitle, + title: title, projectId: this.id, type: type, status: status, @@ -458,8 +600,11 @@ class ProjectProfile { durationMinutes: durationMinutes ?? defaultDurationMinutes, scheduledStart: scheduledStart, scheduledEnd: scheduledEnd, + actualStart: actualStart, + actualEnd: actualEnd, parentTaskId: parentTaskId, backlogTags: backlogTags, + reminderOverride: reminderOverride, createdAt: createdAt, updatedAt: updatedAt ?? createdAt, ); @@ -475,6 +620,7 @@ class ProjectProfile { DifficultyLevel? defaultDifficulty, ReminderProfile? defaultReminderProfile, int? defaultDurationMinutes, + bool clearDefaultDuration = false, }) { return ProjectProfile( id: id ?? this.id, @@ -485,8 +631,9 @@ class ProjectProfile { defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty, defaultReminderProfile: defaultReminderProfile ?? this.defaultReminderProfile, - defaultDurationMinutes: - defaultDurationMinutes ?? this.defaultDurationMinutes, + defaultDurationMinutes: clearDefaultDuration + ? null + : (defaultDurationMinutes ?? this.defaultDurationMinutes), ); } } @@ -498,11 +645,20 @@ class ProjectProfile { /// candidate placements. The interval convention is start-inclusive and /// end-exclusive, which avoids treating two back-to-back blocks as overlapping. class TimeInterval { - const TimeInterval({ + TimeInterval({ required this.start, required this.end, this.label, - }); + }) { + if (!start.isBefore(end)) { + throw DomainValidationException( + code: DomainValidationCode.invalidTimeInterval, + invalidValue: {'start': start, 'end': end}, + name: 'TimeInterval', + message: 'Interval end must be after interval start.', + ); + } + } /// Inclusive beginning of the interval. final DateTime start; @@ -526,3 +682,83 @@ class TimeInterval { return start.isBefore(other.end) && end.isAfter(other.start); } } + +String _requiredTrimmed( + String value, + String name, + DomainValidationCode code, + String message, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: message, + ); + } + return trimmed; +} + +String? _nullableTrimmed( + String? value, + String name, + DomainValidationCode code, + String message, +) { + if (value == null) { + return null; + } + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: message, + ); + } + return trimmed; +} + +void _validatePositiveDuration(int? durationMinutes, String name) { + if (durationMinutes == null) { + return; + } + if (durationMinutes <= 0) { + throw DomainValidationException( + code: DomainValidationCode.nonPositiveDuration, + invalidValue: durationMinutes, + name: name, + message: 'Duration must be positive when present.', + ); + } +} + +void _validateOptionalInterval({ + required DateTime? start, + required DateTime? end, + required DomainValidationCode partialCode, + required DomainValidationCode invalidCode, + required String name, + required String partialMessage, + required String invalidMessage, +}) { + if ((start == null) != (end == null)) { + throw DomainValidationException( + code: partialCode, + invalidValue: {'start': start, 'end': end}, + name: name, + message: partialMessage, + ); + } + if (start != null && end != null && !start.isBefore(end)) { + throw DomainValidationException( + code: invalidCode, + invalidValue: {'start': start, 'end': end}, + name: name, + message: invalidMessage, + ); + } +} diff --git a/lib/src/persistence_contract.dart b/lib/src/persistence_contract.dart index 5899269..270f8b6 100644 --- a/lib/src/persistence_contract.dart +++ b/lib/src/persistence_contract.dart @@ -51,8 +51,11 @@ abstract final class TaskDocumentFields { static const durationMinutes = 'durationMinutes'; static const scheduledStart = 'scheduledStart'; static const scheduledEnd = 'scheduledEnd'; + static const actualStart = 'actualStart'; + static const actualEnd = 'actualEnd'; static const parentTaskId = 'parentTaskId'; static const backlogTags = 'backlogTags'; + static const reminderOverride = 'reminderOverride'; static const createdAt = 'createdAt'; static const updatedAt = 'updatedAt'; static const stats = 'stats'; @@ -69,8 +72,11 @@ abstract final class TaskDocumentFields { durationMinutes, scheduledStart, scheduledEnd, + actualStart, + actualEnd, parentTaskId, backlogTags, + reminderOverride, createdAt, updatedAt, stats, diff --git a/lib/src/quick_capture.dart b/lib/src/quick_capture.dart index 60565a6..3d4b718 100644 --- a/lib/src/quick_capture.dart +++ b/lib/src/quick_capture.dart @@ -31,7 +31,7 @@ enum QuickCaptureStatus { /// possible, but the user can optionally provide enough detail to immediately /// schedule it into the next open flexible slot. class QuickCaptureRequest { - const QuickCaptureRequest({ + QuickCaptureRequest({ required this.id, required this.title, required this.createdAt, @@ -42,8 +42,8 @@ class QuickCaptureRequest { this.difficulty = DifficultyLevel.notSet, this.type = TaskType.flexible, this.durationMinutes, - this.backlogTags = const {}, - }); + Set backlogTags = const {}, + }) : backlogTags = Set.unmodifiable(backlogTags); /// Caller-generated id. Keeping id generation outside this service makes the /// domain layer independent from persistence/database choices. @@ -87,12 +87,12 @@ class QuickCaptureRequest { /// was attempted, [schedulingResult] exposes the lower-level engine notices and /// changes for debugging or timeline updates. class QuickCaptureResult { - const QuickCaptureResult({ + QuickCaptureResult({ required this.task, required this.status, this.schedulingResult, - this.messages = const [], - }); + List messages = const [], + }) : messages = List.unmodifiable(messages); /// Captured task, scheduled or unscheduled depending on [status]. final Task task; @@ -136,7 +136,7 @@ class QuickCaptureService { SchedulingInput? schedulingInput, DateTime? updatedAt, }) { - final capturedTask = Task.quickCapture( + final capturedTaskWithoutDuration = Task.quickCapture( id: request.id, title: request.title, createdAt: request.createdAt, @@ -147,7 +147,19 @@ class QuickCaptureService { difficulty: request.difficulty, backlogTags: request.backlogTags, updatedAt: updatedAt, - ).copyWith(durationMinutes: request.durationMinutes); + ); + + if (request.durationMinutes != null && request.durationMinutes! <= 0) { + return QuickCaptureResult( + task: capturedTaskWithoutDuration, + status: QuickCaptureStatus.validationError, + messages: const ['Duration must be positive when provided.'], + ); + } + + final capturedTask = capturedTaskWithoutDuration.copyWith( + durationMinutes: request.durationMinutes, + ); if (!request.addToNextAvailableSlot) { return QuickCaptureResult( diff --git a/lib/src/repositories.dart b/lib/src/repositories.dart index a1ac240..d6c618b 100644 --- a/lib/src/repositories.dart +++ b/lib/src/repositories.dart @@ -64,18 +64,24 @@ abstract interface class LockedBlockRepository { /// Snapshot of one scheduling operation or persisted planning state. class SchedulingStateSnapshot { - const SchedulingStateSnapshot({ + SchedulingStateSnapshot({ required this.id, required this.capturedAt, required this.window, - required this.tasks, - this.lockedIntervals = const [], - this.requiredVisibleIntervals = const [], - this.notices = const [], - this.changes = const [], - this.overlaps = const [], + required List tasks, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + List notices = const [], + List changes = const [], + List overlaps = const [], this.operationName, - }); + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals), + requiredVisibleIntervals = + List.unmodifiable(requiredVisibleIntervals), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps); /// Stable snapshot id. final String id; diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index 250dfca..121380e 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -43,10 +43,19 @@ enum SchedulingNoticeType { /// flexible tasks can be placed. Anything outside this range is treated as out of /// scope for the operation. class SchedulingWindow { - const SchedulingWindow({ + SchedulingWindow({ required this.start, required this.end, - }); + }) { + if (!start.isBefore(end)) { + throw DomainValidationException( + code: DomainValidationCode.invalidSchedulingWindow, + invalidValue: {'start': start, 'end': end}, + name: 'SchedulingWindow', + message: 'Scheduling window end must be after start.', + ); + } + } /// Inclusive beginning of the scheduling range. final DateTime start; @@ -79,12 +88,15 @@ class SchedulingWindow { /// not know where the data came from: UI state, a database, tests, or generated /// examples can all build this same object. class SchedulingInput { - const SchedulingInput({ - required this.tasks, + SchedulingInput({ + required List tasks, required this.window, - this.lockedIntervals = const [], - this.requiredVisibleIntervals = const [], - }); + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals), + requiredVisibleIntervals = + List.unmodifiable(requiredVisibleIntervals); /// All tasks available to this operation. The scheduler returns a replacement /// list rather than mutating this one. @@ -222,12 +234,15 @@ class SchedulingNotice { /// This keeps the call pattern predictable: always inspect `tasks`, then surface /// any `notices`, `changes`, or `overlaps` relevant to the UI. class SchedulingResult { - const SchedulingResult({ - required this.tasks, - this.notices = const [], - this.changes = const [], - this.overlaps = const [], - }); + SchedulingResult({ + required List tasks, + List notices = const [], + List changes = const [], + List overlaps = const [], + }) : tasks = List.unmodifiable(tasks), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps); /// Replacement task list after the operation. final List tasks; diff --git a/lib/src/timeline_state.dart b/lib/src/timeline_state.dart index 9588ed8..9c662a5 100644 --- a/lib/src/timeline_state.dart +++ b/lib/src/timeline_state.dart @@ -59,7 +59,7 @@ enum TimelineQuickAction { /// Display-ready timeline item derived from a domain [Task]. class TimelineItem { - const TimelineItem({ + TimelineItem({ required this.id, required this.displayTitle, required this.taskType, @@ -68,12 +68,12 @@ class TimelineItem { required this.rewardIconToken, required this.difficultyIconToken, required this.showsExplicitTime, - required this.quickActions, + required List quickActions, required this.category, this.start, this.end, this.durationMinutes, - }); + }) : quickActions = List.unmodifiable(quickActions); /// Stable id of the source item. final String id; @@ -164,9 +164,10 @@ class CompactTimelineState { /// Converts domain tasks into Today timeline items. class TimelineItemMapper { - const TimelineItemMapper({ - this.projectColorTokensById = const {}, - }); + TimelineItemMapper({ + Map projectColorTokensById = const {}, + }) : projectColorTokensById = + Map.unmodifiable(projectColorTokensById); /// Project id to border color token lookup. final Map projectColorTokensById; diff --git a/test/document_mapping_test.dart b/test/document_mapping_test.dart index ce2f76f..956dd78 100644 --- a/test/document_mapping_test.dart +++ b/test/document_mapping_test.dart @@ -36,8 +36,11 @@ void main() { expect(restored.durationMinutes, task.durationMinutes); expect(restored.scheduledStart, task.scheduledStart); expect(restored.scheduledEnd, task.scheduledEnd); + expect(restored.actualStart, task.actualStart); + expect(restored.actualEnd, task.actualEnd); expect(restored.parentTaskId, task.parentTaskId); expect(restored.backlogTags, task.backlogTags); + expect(restored.reminderOverride, task.reminderOverride); expect(restored.createdAt, task.createdAt); expect(restored.updatedAt, task.updatedAt); expect(restored.stats.manuallyPushedCount, 1); @@ -143,8 +146,11 @@ Task scheduledTask({ durationMinutes: 30, scheduledStart: DateTime.utc(2026, 6, 23, 9), scheduledEnd: DateTime.utc(2026, 6, 23, 9, 30), + actualStart: DateTime.utc(2026, 6, 23, 9, 5), + actualEnd: DateTime.utc(2026, 6, 23, 9, 25), parentTaskId: 'parent-1', backlogTags: const {BacklogTag.wishlist}, + reminderOverride: ReminderProfile.persistent, createdAt: createdAt, updatedAt: createdAt.add(const Duration(minutes: 5)), ); diff --git a/test/domain_invariants_test.dart b/test/domain_invariants_test.dart new file mode 100644 index 0000000..f14b902 --- /dev/null +++ b/test/domain_invariants_test.dart @@ -0,0 +1,357 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Domain invariants', () { + final now = DateTime(2026, 6, 24, 9); + + test('task rejects blank identifiers titles and project ids with codes', + () { + expectValidationCode( + () => _task(now: now, id: ' '), + DomainValidationCode.blankStableId, + ); + expectValidationCode( + () => _task(now: now, title: ' '), + DomainValidationCode.blankTitle, + ); + expectValidationCode( + () => _task(now: now, projectId: ' '), + DomainValidationCode.blankProjectId, + ); + }); + + test('task rejects invalid duration and schedule intervals', () { + expectValidationCode( + () => _task(now: now, durationMinutes: 0), + DomainValidationCode.nonPositiveDuration, + ); + expectValidationCode( + () => _task( + now: now, + scheduledStart: DateTime(2026, 6, 24, 9), + ), + DomainValidationCode.partialScheduledInterval, + ); + expectValidationCode( + () => _task( + now: now, + scheduledStart: DateTime(2026, 6, 24, 10), + scheduledEnd: DateTime(2026, 6, 24, 9), + ), + DomainValidationCode.invalidScheduledInterval, + ); + }); + + test('task rejects invalid actual intervals and self parenting', () { + expectValidationCode( + () => _task( + now: now, + actualStart: DateTime(2026, 6, 24, 9), + ), + DomainValidationCode.partialActualInterval, + ); + expectValidationCode( + () => _task( + now: now, + actualStart: DateTime(2026, 6, 24, 10), + actualEnd: DateTime(2026, 6, 24, 9), + ), + DomainValidationCode.invalidActualInterval, + ); + expectValidationCode( + () => _task(now: now, id: 'task-1', parentTaskId: 'task-1'), + DomainValidationCode.selfParent, + ); + }); + + test('copyWith has explicit clear paths for nullable task fields', () { + final task = _task( + now: now, + priority: PriorityLevel.high, + durationMinutes: 25, + scheduledStart: DateTime(2026, 6, 24, 9), + scheduledEnd: DateTime(2026, 6, 24, 9, 25), + actualStart: DateTime(2026, 6, 24, 9, 5), + actualEnd: DateTime(2026, 6, 24, 9, 20), + parentTaskId: 'parent-1', + reminderOverride: ReminderProfile.strict, + ); + + final preserved = task.copyWith(title: 'Renamed'); + final cleared = task.copyWith( + clearPriority: true, + clearDuration: true, + clearSchedule: true, + clearActualInterval: true, + clearParentTask: true, + clearReminderOverride: true, + ); + + expect(preserved.priority, PriorityLevel.high); + expect(preserved.durationMinutes, 25); + expect(preserved.scheduledStart, DateTime(2026, 6, 24, 9)); + expect(preserved.actualStart, DateTime(2026, 6, 24, 9, 5)); + expect(preserved.parentTaskId, 'parent-1'); + expect(preserved.reminderOverride, ReminderProfile.strict); + + expect(cleared.priority, isNull); + expect(cleared.durationMinutes, isNull); + expect(cleared.scheduledStart, isNull); + expect(cleared.scheduledEnd, isNull); + expect(cleared.actualStart, isNull); + expect(cleared.actualEnd, isNull); + expect(cleared.parentTaskId, isNull); + expect(cleared.reminderOverride, isNull); + }); + + test('mutable task collections do not leak into domain objects', () { + final tags = {BacklogTag.wishlist}; + final task = _task(now: now, backlogTags: tags); + + tags.clear(); + + expect(task.backlogTags, {BacklogTag.wishlist}); + expect(() => task.backlogTags.clear(), throwsUnsupportedError); + }); + + test('quick capture reports invalid durations without throwing', () { + const service = QuickCaptureService(); + final result = service.capture( + QuickCaptureRequest( + id: 'capture-1', + title: 'Buy stamps', + createdAt: now, + durationMinutes: -5, + ), + ); + + expect(result.status, QuickCaptureStatus.validationError); + expect(result.task.durationMinutes, isNull); + expect(result.messages.single, contains('positive')); + }); + + test('project profile validates identifiers and default duration', () { + expectValidationCode( + () => ProjectProfile(id: ' ', name: 'Home', colorKey: 'blue'), + DomainValidationCode.blankStableId, + ); + expectValidationCode( + () => ProjectProfile(id: 'home', name: ' ', colorKey: 'blue'), + DomainValidationCode.blankName, + ); + expectValidationCode( + () => ProjectProfile(id: 'home', name: 'Home', colorKey: ' '), + DomainValidationCode.blankColorKey, + ); + expectValidationCode( + () => ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'blue', + defaultDurationMinutes: -1, + ), + DomainValidationCode.nonPositiveDuration, + ); + + final project = ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'blue', + defaultDurationMinutes: 20, + ); + expect( + project.copyWith(clearDefaultDuration: true).defaultDurationMinutes, + isNull); + }); + + test('time interval and scheduling window reject non-positive ranges', () { + expectValidationCode( + () => TimeInterval( + start: DateTime(2026, 6, 24, 9), + end: DateTime(2026, 6, 24, 9), + ), + DomainValidationCode.invalidTimeInterval, + ); + expectValidationCode( + () => SchedulingWindow( + start: DateTime(2026, 6, 24, 10), + end: DateTime(2026, 6, 24, 9), + ), + DomainValidationCode.invalidSchedulingWindow, + ); + }); + }); + + group('Locked-time invariants', () { + final now = DateTime(2026, 6, 24, 9); + + test('clock time and recurrence validate real values', () { + expectValidationCode( + () => ClockTime(hour: 24, minute: 0), + DomainValidationCode.invalidClockTime, + ); + expectValidationCode( + () => LockedBlockRecurrence.weekly(weekdays: const {}), + DomainValidationCode.emptyRecurrence, + ); + }); + + test('locked blocks require a source date or recurrence and positive time', + () { + expectValidationCode( + () => LockedBlock( + id: 'locked-1', + name: 'Work', + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 10, minute: 0), + createdAt: now, + updatedAt: now, + ), + DomainValidationCode.invalidLockedBlock, + ); + expectValidationCode( + () => LockedBlock( + id: 'locked-1', + name: 'Work', + startTime: ClockTime(hour: 10, minute: 0), + endTime: ClockTime(hour: 9, minute: 0), + date: now, + createdAt: now, + updatedAt: now, + ), + DomainValidationCode.invalidLockedBlock, + ); + }); + + test('locked block overrides enforce valid shapes', () { + expectValidationCode( + () => LockedBlockOverride.add( + id: 'override-1', + date: now, + name: 'Extra work', + startTime: ClockTime(hour: 12, minute: 0), + endTime: ClockTime(hour: 11, minute: 0), + createdAt: now, + ), + DomainValidationCode.invalidLockedOverride, + ); + expectValidationCode( + () => LockedBlockOverride( + id: 'override-2', + date: now, + type: LockedBlockOverrideType.remove, + name: 'Invalid remove', + createdAt: now, + updatedAt: now, + ), + DomainValidationCode.invalidLockedOverride, + ); + }); + }); + + group('Collection immutability', () { + final now = DateTime(2026, 6, 24, 9); + + test('scheduling input and result copy mutable lists', () { + final task = _task(now: now); + final sourceTasks = [task]; + final input = SchedulingInput( + tasks: sourceTasks, + window: SchedulingWindow( + start: DateTime(2026, 6, 24, 9), + end: DateTime(2026, 6, 24, 10), + ), + ); + final result = SchedulingResult(tasks: sourceTasks); + + sourceTasks.clear(); + + expect(input.tasks, [task]); + expect(result.tasks, [task]); + expect(() => input.tasks.add(task), throwsUnsupportedError); + expect(() => result.tasks.add(task), throwsUnsupportedError); + }); + + test('view and action result collections are immutable snapshots', () { + final task = _task(now: now); + final tasks = [task]; + final backlogView = BacklogView(tasks: tasks, now: now); + final childView = ChildTaskView(tasks: tasks); + final quickResult = QuickCaptureResult( + task: task, + status: QuickCaptureStatus.validationError, + messages: ['Need duration'], + ); + final completionResult = ChildTaskCompletionResult( + tasks: tasks, + changedTaskIds: ['task-1'], + ); + + tasks.clear(); + + expect(backlogView.tasks, [task]); + expect(childView.tasks, [task]); + expect(quickResult.messages, ['Need duration']); + expect(completionResult.tasks, [task]); + expect(() => backlogView.tasks.add(task), throwsUnsupportedError); + expect(() => childView.tasks.add(task), throwsUnsupportedError); + expect(() => quickResult.messages.add('Other'), throwsUnsupportedError); + expect(() => completionResult.changedTaskIds.add('task-2'), + throwsUnsupportedError); + }); + }); +} + +void expectValidationCode( + Object? Function() callback, + DomainValidationCode code, +) { + expect( + callback, + throwsA( + isA().having( + (error) => error.code, + 'code', + code, + ), + ), + ); +} + +Task _task({ + required DateTime now, + String id = 'task-1', + String title = 'Task', + String projectId = 'project-1', + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.planned, + PriorityLevel? priority, + int? durationMinutes, + DateTime? scheduledStart, + DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, + String? parentTaskId, + Set backlogTags = const {}, + ReminderProfile? reminderOverride, +}) { + return Task( + id: id, + title: title, + projectId: projectId, + type: type, + status: status, + priority: priority, + durationMinutes: durationMinutes, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + actualStart: actualStart, + actualEnd: actualEnd, + parentTaskId: parentTaskId, + backlogTags: backlogTags, + reminderOverride: reminderOverride, + createdAt: now, + updatedAt: now, + ); +} diff --git a/test/edge_case_regression_test.dart b/test/edge_case_regression_test.dart index bea21f3..9b28cef 100644 --- a/test/edge_case_regression_test.dart +++ b/test/edge_case_regression_test.dart @@ -44,7 +44,7 @@ void main() { test('project profile trims and validates task titles like quick capture', () { - const project = ProjectProfile( + final project = ProjectProfile( id: 'health', name: 'Health', colorKey: 'green', @@ -562,9 +562,9 @@ void main() { final block = LockedBlock( id: 'work', name: 'Work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 17, minute: 0), - recurrence: const LockedBlockRecurrence.weekly( + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.friday}, ), createdAt: now, diff --git a/test/persistence_edge_cases_test.dart b/test/persistence_edge_cases_test.dart index c00c18c..77ce4b6 100644 --- a/test/persistence_edge_cases_test.dart +++ b/test/persistence_edge_cases_test.dart @@ -23,7 +23,7 @@ void main() { }); test('project ids remain stable across copy and update operations', () { - const original = ProjectProfile( + final original = ProjectProfile( id: 'project-1', name: 'Project', colorKey: 'project-blue', @@ -44,9 +44,9 @@ void main() { final original = LockedBlock( id: 'locked-1', name: 'Work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 17, minute: 0), - recurrence: const LockedBlockRecurrence.weekly( + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.monday}, ), createdAt: createdAt, @@ -192,8 +192,11 @@ void main() { 'durationMinutes', 'scheduledStart', 'scheduledEnd', + 'actualStart', + 'actualEnd', 'parentTaskId', 'backlogTags', + 'reminderOverride', 'createdAt', 'updatedAt', 'stats', diff --git a/test/repositories_test.dart b/test/repositories_test.dart index fcde21b..6b5263e 100644 --- a/test/repositories_test.dart +++ b/test/repositories_test.dart @@ -38,7 +38,7 @@ void main() { }); test('project repository stores project profiles by stable id', () async { - const project = ProjectProfile( + final project = ProjectProfile( id: 'home', name: 'Home', colorKey: 'project-home', @@ -55,9 +55,9 @@ void main() { final block = LockedBlock( id: 'work', name: 'Work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 17, minute: 0), - recurrence: const LockedBlockRecurrence.weekly( + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.monday}, ), createdAt: createdAt, diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index 88e047f..dfd6b1c 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -356,7 +356,7 @@ void main() { }); test('project defaults apply while task overrides remain possible', () { - const project = ProjectProfile( + final project = ProjectProfile( id: 'home', name: 'Home', colorKey: 'green', @@ -409,8 +409,8 @@ void main() { final oneOff = LockedBlock( id: 'appointment', name: 'Appointment', - startTime: const ClockTime(hour: 13, minute: 0), - endTime: const ClockTime(hour: 14, minute: 0), + startTime: ClockTime(hour: 13, minute: 0), + endTime: ClockTime(hour: 14, minute: 0), date: DateTime(2026, 6, 19), createdAt: now, updatedAt: now, @@ -419,9 +419,9 @@ void main() { final workHours = LockedBlock( id: 'work-hours', name: 'Work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 17, minute: 0), - recurrence: const LockedBlockRecurrence.weekly( + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, @@ -470,7 +470,7 @@ void main() { test('locked block override modifies one date without recurrence changes', () { - final recurrence = const LockedBlockRecurrence.weekly( + final recurrence = LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, @@ -482,8 +482,8 @@ void main() { final workHours = LockedBlock( id: 'work-hours', name: 'Work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 17, minute: 0), + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), recurrence: recurrence, createdAt: now, updatedAt: now, @@ -492,8 +492,8 @@ void main() { id: 'half-day', lockedBlockId: 'work-hours', date: DateTime(2026, 6, 19), - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 12, minute: 0), + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 12, minute: 0), createdAt: now, ); final interval = halfDay.intervalForDate(DateTime(2026, 6, 19)); @@ -510,8 +510,8 @@ void main() { id: 'surprise-lock', date: DateTime(2026, 6, 19), name: 'Urgent errand', - startTime: const ClockTime(hour: 15, minute: 0), - endTime: const ClockTime(hour: 16, minute: 0), + startTime: ClockTime(hour: 15, minute: 0), + endTime: ClockTime(hour: 16, minute: 0), createdAt: now, projectId: 'home', ); @@ -529,9 +529,9 @@ void main() { final workHours = LockedBlock( id: 'work-hours', name: 'Work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 17, minute: 0), - recurrence: const LockedBlockRecurrence.weekly( + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, @@ -571,9 +571,9 @@ void main() { final workHours = LockedBlock( id: 'work-hours', name: 'Work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 17, minute: 0), - recurrence: const LockedBlockRecurrence.weekly( + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( weekdays: { LockedWeekday.monday, LockedWeekday.tuesday, @@ -597,8 +597,8 @@ void main() { lockedBlockId: 'work-hours', date: DateTime(2026, 6, 22), name: 'Half-day work', - startTime: const ClockTime(hour: 9, minute: 0), - endTime: const ClockTime(hour: 12, minute: 0), + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 12, minute: 0), hiddenByDefault: false, createdAt: now.add(const Duration(minutes: 1)), ); @@ -636,8 +636,8 @@ void main() { id: 'surprise-lock', date: DateTime(2026, 6, 19), name: 'Urgent errand', - startTime: const ClockTime(hour: 15, minute: 0), - endTime: const ClockTime(hour: 16, minute: 0), + startTime: ClockTime(hour: 15, minute: 0), + endTime: ClockTime(hour: 16, minute: 0), createdAt: now, projectId: 'home', ); @@ -1098,7 +1098,8 @@ void main() { ), ); - expect(result.tasks, same(tasks)); + expect(result.tasks.single, same(flexible)); + expect(() => result.tasks.add(flexible), throwsUnsupportedError); expect(result.changes, isEmpty); expect(result.overlaps.single.taskId, 'task-1'); expect(result.overlaps.single.blockedInterval.label, 'locked-block'); @@ -1240,9 +1241,9 @@ void main() { final workHours = LockedBlock( id: 'work-hours', name: 'Work', - startTime: const ClockTime(hour: 13, minute: 0), - endTime: const ClockTime(hour: 13, minute: 20), - recurrence: const LockedBlockRecurrence.weekly( + startTime: ClockTime(hour: 13, minute: 0), + endTime: ClockTime(hour: 13, minute: 20), + recurrence: LockedBlockRecurrence.weekly( weekdays: {LockedWeekday.friday}, ), hiddenByDefault: true, @@ -1253,8 +1254,8 @@ void main() { id: 'longer-work', lockedBlockId: 'work-hours', date: DateTime(2026, 6, 19), - startTime: const ClockTime(hour: 13, minute: 0), - endTime: const ClockTime(hour: 13, minute: 30), + startTime: ClockTime(hour: 13, minute: 0), + endTime: ClockTime(hour: 13, minute: 30), createdAt: now, ); final lockedIntervals = lockedSchedulingIntervalsForDay( @@ -1351,7 +1352,8 @@ void main() { updatedAt: now, ); - expect(result.tasks, same(tasks)); + expect(result.tasks.single, same(backlogTask)); + expect(() => result.tasks.add(backlogTask), throwsUnsupportedError); expect(result.changes, isEmpty); expect(result.notices.single.type, SchedulingNoticeType.overflow); expect(result.notices.single.taskId, 'backlog-1'); diff --git a/test/timeline_state_test.dart b/test/timeline_state_test.dart index 6816c5c..e6b441d 100644 --- a/test/timeline_state_test.dart +++ b/test/timeline_state_test.dart @@ -6,12 +6,12 @@ void main() { final createdAt = DateTime(2026, 6, 20, 8); final start = DateTime(2026, 6, 20, 9); final end = DateTime(2026, 6, 20, 9, 30); - const project = ProjectProfile( + final project = ProjectProfile( id: 'home', name: 'Home', colorKey: 'project-home', ); - final mapper = TimelineItemMapper.fromProjects(const [project]); + final mapper = TimelineItemMapper.fromProjects([project]); test('maps task fields to display tokens', () { final task = scheduledTask(