From 6612a5629a06a2684ceaf0a9fa0ac0c4911dacbd Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 24 Jun 2026 11:47:40 -0700 Subject: [PATCH] feat(tasks): implement surprise task logging --- ...k_Actions_State_Transitions_SUPERSEDED.md} | 0 ..._Task_Actions_State_Transitions_UPDATED.md | 17 +- .../Current Software Plan/README.md | 5 +- .../Starter Architecture Notes.md | 5 + lib/src/scheduling_engine.dart | 5 +- lib/src/task_actions.dart | 293 ++++++++++++++++++ test/surprise_task_logging_test.dart | 287 +++++++++++++++++ 7 files changed, 607 insertions(+), 5 deletions(-) rename Codex Documentation/{Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions.md => Archived plans/V1_BLOCK_06_Task_Actions_State_Transitions_SUPERSEDED.md} (100%) rename Codex Documentation/{Current Software Plan => Archived plans}/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md (92%) create mode 100644 test/surprise_task_logging_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions.md b/Codex Documentation/Archived plans/V1_BLOCK_06_Task_Actions_State_Transitions_SUPERSEDED.md similarity index 100% rename from Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions.md rename to Codex Documentation/Archived plans/V1_BLOCK_06_Task_Actions_State_Transitions_SUPERSEDED.md diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md b/Codex Documentation/Archived plans/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md similarity index 92% rename from Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md rename to Codex Documentation/Archived plans/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md index 0f0db51..e14ac78 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md @@ -1,6 +1,6 @@ # V1 Block 06 — Task Actions and State Transitions -Status: Planned +Status: Completed Purpose: Define and implement the safe, low-friction actions available from task cards. @@ -306,7 +306,7 @@ BREAKPOINT: Stop here. Confirm `high` mode before implementing Chunk 6.5 surpris Recommended Codex level: high -Status: Planned +Status: Completed Tasks: @@ -335,3 +335,16 @@ Commit suggestion: ```text feat(tasks): implement surprise task logging ``` + +Execution notes: + +- Added `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, and `SurpriseTaskLogService`. +- Surprise logging creates a completed `TaskType.surprise` task and trims/validates the title. +- Optional fields can be omitted; omitted time data saves an unplaced completed surprise task without blocking the log. +- When start time and time-used are supplied, the surprise task occupies that interval. +- Overlapping planned flexible tasks are moved using the existing `pushFlexibleTaskToNextAvailableSlot` behavior with the surprise interval treated as blocked time. +- Surprise-caused flexible movement increments auto-push statistics rather than manual-push statistics. +- Inflexible and critical overlaps are reported through visible overlap data and are not moved. +- Locked overlaps are tracked separately from normal task/notice output so locked time remains hidden by default. +- Added `test/surprise_task_logging_test.dart` for surprise creation, flexible movement, fixed overlap reporting, locked overlap tracking, optional fields, and cascade movement safety. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. diff --git a/Codex Documentation/Current Software Plan/README.md b/Codex Documentation/Current Software Plan/README.md index 27b9bca..414a9c2 100644 --- a/Codex Documentation/Current Software Plan/README.md +++ b/Codex Documentation/Current Software Plan/README.md @@ -1,6 +1,7 @@ # Current Software Plan -Execute V1 block documents in numeric order. +Execute active V1 block documents in numeric order. Blocks 01-06 are completed +and archived; the next active block is Block 07. ## Execution rules @@ -30,7 +31,7 @@ Do not infer a new level name. 3. `V1_BLOCK_03_Scheduling_Engine.md` 4. `V1_BLOCK_04_Backlog_Quick_Capture.md` 5. `V1_BLOCK_05_Recurring_Locked_Blocks.md` -6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md` +6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md` — archived 7. `V1_BLOCK_07_Child_Tasks.md` 8. `V1_BLOCK_08_Today_Timeline_State.md` 9. `V1_BLOCK_09_Persistence_Preparation.md` diff --git a/Human Documentation/Starter Architecture Notes.md b/Human Documentation/Starter Architecture Notes.md index a1d8b09..182bace 100644 --- a/Human Documentation/Starter Architecture Notes.md +++ b/Human Documentation/Starter Architecture Notes.md @@ -40,6 +40,11 @@ the stale filter and green/blue/purple marker. A later MongoDB-backed persistence pass may add a separate backlog-entered timestamp, but the current core keeps the timestamp semantics consistent and database-independent. +Surprise task logging is a domain action, not UI behavior. A logged surprise task +is completed immediately. If it has a time interval, overlapping flexible tasks +are pushed by the normal scheduler, required visible overlaps are reported, and +locked overlaps are tracked as hidden data rather than rendered as normal tasks. + ## Persistence direction MongoDB is the committed persistence target. The V1 scheduling core should still diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index f308704..250dfca 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -353,6 +353,7 @@ class SchedulingEngine { required SchedulingInput input, required String taskId, DateTime? updatedAt, + bool countAsManualPush = true, }) { // Resolve the selected task by id so UI code only needs to pass a stable // identifier, not object references. @@ -425,6 +426,7 @@ class SchedulingEngine { placement: placement, pushedTaskMessage: 'Flexible task pushed to next available slot.', updatedAt: updatedAt ?? DateTime.now(), + countPushedTaskAsManual: countAsManualPush, ); } @@ -1140,6 +1142,7 @@ SchedulingResult _applyPushPlacement({ required _BacklogInsertionPlan placement, required String pushedTaskMessage, required DateTime updatedAt, + bool countPushedTaskAsManual = true, }) { final changes = []; final notices = []; @@ -1165,7 +1168,7 @@ SchedulingResult _applyPushPlacement({ scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, - stats: isPushedTask + stats: isPushedTask && countPushedTaskAsManual ? task.stats.incrementManualPush() : task.stats.incrementAutoPush(), ); diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index c9cdbbc..eab7ef7 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -137,6 +137,78 @@ class RequiredTaskActionResult { final bool removedFromActivePlan; } +/// Input for logging work the user already did outside the plan. +/// +/// The user may know only a title, or may also know when it happened and how +/// long it took. When time data is present, the resulting surprise task occupies +/// that interval and flexible work overlapping it is pushed out of the way. +class SurpriseTaskLogRequest { + const SurpriseTaskLogRequest({ + required this.id, + required this.title, + required this.createdAt, + this.startedAt, + this.timeUsedMinutes, + this.projectId = 'inbox', + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + }); + + /// Caller-generated id for the new surprise task. + final String id; + + /// User-entered title. + final String title; + + /// Creation/log timestamp supplied by the caller for testability. + final DateTime createdAt; + + /// When the surprise work started, if known. + final DateTime? startedAt; + + /// Minutes spent on the surprise work, if known. + final int? timeUsedMinutes; + + /// Project id to assign; defaults to inbox when omitted. + final String projectId; + + /// Optional priority. Null means the user did not set one. + final PriorityLevel? priority; + + /// Optional reward estimate. + final RewardLevel reward; + + /// Optional difficulty estimate. + final DifficultyLevel difficulty; +} + +/// Result from logging a surprise task. +class SurpriseTaskLogResult { + const SurpriseTaskLogResult({ + required this.surpriseTask, + required this.schedulingResult, + this.requiredOverlaps = const [], + this.lockedOverlaps = const [], + this.usedNormalPushRules = false, + }); + + /// Newly created completed surprise task. + final Task surpriseTask; + + /// Updated task list, movement notices, changes, and visible overlaps. + final SchedulingResult schedulingResult; + + /// Critical/inflexible overlaps with the surprise interval. + final List requiredOverlaps; + + /// Locked overlaps tracked separately so they stay hidden by default. + final List lockedOverlaps; + + /// Whether flexible movement was delegated to normal push behavior. + final bool usedNormalPushRules; +} + /// Applies low-friction quick actions for flexible task cards. /// /// This service is the adapter between small UI button presses and domain logic. @@ -386,6 +458,150 @@ class RequiredTaskActionService { } } +/// Logs surprise completed work and repairs affected flexible tasks. +/// +/// Surprise work is already done, so the new task is created as completed. When +/// it has a concrete interval, overlapping planned flexible tasks are moved by +/// the existing push scheduler with the surprise interval treated as blocked +/// time. Required visible and locked time is never moved. +class SurpriseTaskLogService { + const SurpriseTaskLogService({ + this.schedulingEngine = const SchedulingEngine(), + }); + + /// Scheduling dependency used to move overlapping flexible tasks. + final SchedulingEngine schedulingEngine; + + /// Log one surprise task against a scheduling snapshot. + SurpriseTaskLogResult log({ + required SurpriseTaskLogRequest request, + required SchedulingInput input, + DateTime? updatedAt, + }) { + final now = updatedAt ?? DateTime.now(); + final surpriseTask = _createSurpriseTask(request, updatedAt: now); + final surpriseInterval = _scheduledIntervalForTask(surpriseTask); + var currentTasks = [surpriseTask, ...input.tasks]; + + if (surpriseInterval == null) { + return SurpriseTaskLogResult( + surpriseTask: surpriseTask, + schedulingResult: SchedulingResult( + tasks: List.unmodifiable(currentTasks), + notices: const [ + SchedulingNotice('Surprise task logged.'), + ], + ), + ); + } + + final requiredOverlaps = _requiredOverlaps( + tasks: input.requiredVisibleTasks, + surpriseInterval: surpriseInterval, + ); + final lockedOverlaps = _lockedOverlaps( + input: input, + surpriseInterval: surpriseInterval, + ); + final flexibleTaskIds = _overlappingFlexibleTaskIds( + input.flexibleTasks, + surpriseInterval, + ); + final changes = []; + final movementNotices = []; + var usedNormalPushRules = false; + + for (final taskId in flexibleTaskIds) { + final currentTask = _taskById(currentTasks, taskId); + final currentInterval = + currentTask == null ? null : _scheduledIntervalForTask(currentTask); + if (currentTask == null || + currentInterval == null || + !currentInterval.overlaps(surpriseInterval)) { + continue; + } + + final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: currentTasks, + window: input.window, + lockedIntervals: [ + ...input.lockedIntervals, + surpriseInterval, + ], + requiredVisibleIntervals: input.requiredVisibleIntervals, + ), + taskId: taskId, + updatedAt: now, + countAsManualPush: false, + ); + + usedNormalPushRules = true; + currentTasks = pushResult.tasks; + changes.addAll(pushResult.changes); + movementNotices.addAll(pushResult.notices); + } + + final overlapNotices = requiredOverlaps + .map( + (overlap) => SchedulingNotice( + 'Surprise task overlaps required visible time.', + type: SchedulingNoticeType.overlap, + taskId: overlap.taskId, + ), + ) + .toList(growable: false); + + return SurpriseTaskLogResult( + surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, + schedulingResult: SchedulingResult( + tasks: List.unmodifiable(currentTasks), + notices: List.unmodifiable([ + const SchedulingNotice('Surprise task logged.'), + ...movementNotices, + ...overlapNotices, + ]), + changes: List.unmodifiable(changes), + overlaps: List.unmodifiable(requiredOverlaps), + ), + requiredOverlaps: List.unmodifiable(requiredOverlaps), + lockedOverlaps: List.unmodifiable(lockedOverlaps), + usedNormalPushRules: usedNormalPushRules, + ); + } + + Task _createSurpriseTask( + SurpriseTaskLogRequest request, { + required DateTime updatedAt, + }) { + final trimmedTitle = request.title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(request.title, 'title', 'Title is required.'); + } + + final duration = request.timeUsedMinutes; + final start = request.startedAt; + final hasScheduledTime = start != null && duration != null && duration > 0; + + return Task( + id: request.id, + title: trimmedTitle, + projectId: request.projectId, + type: TaskType.surprise, + status: TaskStatus.completed, + priority: request.priority, + reward: request.reward, + difficulty: request.difficulty, + durationMinutes: duration != null && duration > 0 ? duration : null, + scheduledStart: hasScheduledTime ? start : null, + scheduledEnd: + hasScheduledTime ? start.add(Duration(minutes: duration)) : null, + createdAt: request.createdAt, + updatedAt: updatedAt, + ); + } +} + /// Find one task by id in a list. Task? _taskById(List tasks, String taskId) { for (final task in tasks) { @@ -396,3 +612,80 @@ Task? _taskById(List tasks, String taskId) { return null; } + +/// Build an interval for a task, or null when it has no usable placement. +TimeInterval? _scheduledIntervalForTask(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + if (start == null || end == null || !start.isBefore(end)) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +/// Required visible overlaps with a surprise interval. +List _requiredOverlaps({ + required List tasks, + required TimeInterval surpriseInterval, +}) { + final overlaps = []; + + for (final task in tasks) { + final interval = _scheduledIntervalForTask(task); + if (interval == null || !interval.overlaps(surpriseInterval)) { + continue; + } + + overlaps.add( + SchedulingOverlap( + taskId: task.id, + taskInterval: interval, + blockedInterval: surpriseInterval, + ), + ); + } + + return overlaps; +} + +/// Locked overlaps with a surprise interval, kept hidden from normal notices. +List _lockedOverlaps({ + required SchedulingInput input, + required TimeInterval surpriseInterval, +}) { + final lockedIntervals = [ + ...input.lockedIntervals, + ...input.lockedTasks + .map(_scheduledIntervalForTask) + .whereType(), + ]; + + return lockedIntervals + .where((interval) => interval.overlaps(surpriseInterval)) + .toList(growable: false); +} + +/// Planned flexible task ids overlapping a surprise interval in timeline order. +List _overlappingFlexibleTaskIds( + List tasks, + TimeInterval surpriseInterval, +) { + final overlappingTasks = tasks.where((task) { + if (task.status != TaskStatus.planned) { + return false; + } + + final interval = _scheduledIntervalForTask(task); + return interval != null && interval.overlaps(surpriseInterval); + }).toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? surpriseInterval.end; + final bStart = b.scheduledStart ?? surpriseInterval.end; + + return aStart.compareTo(bStart); + }); + + return overlappingTasks.map((task) => task.id).toList(growable: false); +} diff --git a/test/surprise_task_logging_test.dart b/test/surprise_task_logging_test.dart new file mode 100644 index 0000000..49e04d2 --- /dev/null +++ b/test/surprise_task_logging_test.dart @@ -0,0 +1,287 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Surprise task logging', () { + final now = DateTime(2026, 6, 19, 12); + const service = SurpriseTaskLogService(); + + test('creates a completed surprise task that occupies the logged time', () { + final result = service.log( + request: SurpriseTaskLogRequest( + id: 'surprise-1', + title: 'Answered urgent text', + createdAt: now, + startedAt: DateTime(2026, 6, 19, 9), + timeUsedMinutes: 20, + projectId: 'relationships', + priority: PriorityLevel.high, + reward: RewardLevel.medium, + ), + input: emptyInput(), + updatedAt: now, + ); + + expect(result.surpriseTask.type, TaskType.surprise); + expect(result.surpriseTask.status, TaskStatus.completed); + expect(result.surpriseTask.title, 'Answered urgent text'); + expect(result.surpriseTask.projectId, 'relationships'); + expect(result.surpriseTask.priority, PriorityLevel.high); + expect(result.surpriseTask.reward, RewardLevel.medium); + expect(result.surpriseTask.durationMinutes, 20); + expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9)); + expect(result.surpriseTask.scheduledEnd, DateTime(2026, 6, 19, 9, 20)); + expect( + result.schedulingResult.tasks.map((task) => task.id), + ['surprise-1'], + ); + }); + + test('pushes overlapping flexible tasks using normal push rules', () { + final flexible = plannedFlexibleTask( + id: 'flexible', + title: 'Write notes', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 30, + ); + + final result = service.log( + request: SurpriseTaskLogRequest( + id: 'surprise-1', + title: 'Unplanned call', + createdAt: now, + startedAt: DateTime(2026, 6, 19, 9, 10), + timeUsedMinutes: 20, + ), + input: SchedulingInput( + tasks: [flexible], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 11), + ), + ), + updatedAt: now, + ); + final moved = taskById(result.schedulingResult.tasks, 'flexible'); + + expect(moved.scheduledStart, DateTime(2026, 6, 19, 9, 30)); + expect(moved.scheduledEnd, DateTime(2026, 6, 19, 10)); + expect(moved.stats.autoPushedCount, 1); + expect(moved.stats.manuallyPushedCount, 0); + expect(result.schedulingResult.changes.single.taskId, 'flexible'); + expect(result.usedNormalPushRules, isTrue); + }); + + test('does not move inflexible or critical overlaps and reports them', () { + final inflexible = fixedTask( + id: 'appointment', + type: TaskType.inflexible, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 30, + ); + final critical = fixedTask( + id: 'meds', + type: TaskType.critical, + createdAt: now, + start: DateTime(2026, 6, 19, 9, 20), + durationMinutes: 20, + ); + + final result = service.log( + request: SurpriseTaskLogRequest( + id: 'surprise-1', + title: 'Unplanned call', + createdAt: now, + startedAt: DateTime(2026, 6, 19, 9, 10), + timeUsedMinutes: 20, + ), + input: SchedulingInput( + tasks: [inflexible, critical], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 11), + ), + ), + updatedAt: now, + ); + + expect(taskById(result.schedulingResult.tasks, 'appointment'), + sameScheduleAs(inflexible)); + expect(taskById(result.schedulingResult.tasks, 'meds'), + sameScheduleAs(critical)); + expect( + result.requiredOverlaps.map((overlap) => overlap.taskId), + ['appointment', 'meds'], + ); + expect( + result.schedulingResult.overlaps.map((overlap) => overlap.taskId), + ['appointment', 'meds'], + ); + }); + + test('tracks locked overlap without rendering locked time as a task', () { + final result = service.log( + request: SurpriseTaskLogRequest( + id: 'surprise-1', + title: 'Unplanned call', + createdAt: now, + startedAt: DateTime(2026, 6, 19, 9, 10), + timeUsedMinutes: 20, + ), + input: SchedulingInput( + tasks: const [], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 11), + ), + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + label: 'hidden-work', + ), + ], + ), + updatedAt: now, + ); + + expect(result.lockedOverlaps.single.label, 'hidden-work'); + expect(result.schedulingResult.tasks.map((task) => task.type), [ + TaskType.surprise, + ]); + expect(result.schedulingResult.overlaps, isEmpty); + }); + + test('optional fields can be omitted without blocking save', () { + final result = service.log( + request: SurpriseTaskLogRequest( + id: 'surprise-1', + title: ' Took a break ', + createdAt: now, + ), + input: emptyInput(), + updatedAt: now, + ); + + expect(result.surpriseTask.title, 'Took a break'); + expect(result.surpriseTask.projectId, 'inbox'); + expect(result.surpriseTask.priority, isNull); + expect(result.surpriseTask.reward, RewardLevel.notSet); + expect(result.surpriseTask.durationMinutes, isNull); + expect(result.surpriseTask.scheduledStart, isNull); + expect(result.surpriseTask.scheduledEnd, isNull); + expect(result.schedulingResult.tasks.single.id, 'surprise-1'); + }); + + test('does not push the same flexible task twice after cascade movement', + () { + final first = plannedFlexibleTask( + id: 'first', + title: 'First', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 30, + ); + final second = plannedFlexibleTask( + id: 'second', + title: 'Second', + createdAt: now, + start: DateTime(2026, 6, 19, 9, 30), + durationMinutes: 30, + ); + + final result = service.log( + request: SurpriseTaskLogRequest( + id: 'surprise-1', + title: 'Unplanned call', + createdAt: now, + startedAt: DateTime(2026, 6, 19, 9, 10), + timeUsedMinutes: 40, + ), + input: SchedulingInput( + tasks: [first, second], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 12), + ), + ), + updatedAt: now, + ); + + expect(taskById(result.schedulingResult.tasks, 'first').scheduledStart, + DateTime(2026, 6, 19, 9, 50)); + expect(taskById(result.schedulingResult.tasks, 'second').scheduledStart, + DateTime(2026, 6, 19, 10, 20)); + expect( + result.schedulingResult.changes.map((change) => change.taskId), + ['first', 'second'], + ); + }); + }); +} + +SchedulingInput emptyInput() { + return SchedulingInput( + tasks: const [], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 17), + ), + ); +} + +Task plannedFlexibleTask({ + required String id, + required String title, + required DateTime createdAt, + required DateTime start, + required int durationMinutes, +}) { + return Task.quickCapture( + id: id, + title: title, + createdAt: createdAt, + ).copyWith( + status: TaskStatus.planned, + durationMinutes: durationMinutes, + scheduledStart: start, + scheduledEnd: start.add(Duration(minutes: durationMinutes)), + ); +} + +Task fixedTask({ + required String id, + required TaskType type, + required DateTime createdAt, + required DateTime start, + required int durationMinutes, +}) { + return Task( + id: id, + title: id, + projectId: 'fixed', + type: type, + status: TaskStatus.planned, + priority: PriorityLevel.high, + durationMinutes: durationMinutes, + scheduledStart: start, + scheduledEnd: start.add(Duration(minutes: durationMinutes)), + createdAt: createdAt, + updatedAt: createdAt, + ); +} + +Task taskById(List tasks, String id) { + return tasks.singleWhere((task) => task.id == id); +} + +Matcher sameScheduleAs(Task expected) { + return predicate((actual) { + return actual.scheduledStart == expected.scheduledStart && + actual.scheduledEnd == expected.scheduledEnd && + actual.status == expected.status && + actual.type == expected.type; + }, 'same schedule and state as ${expected.id}'); +}