feat(app): add app-open rollover recovery
This commit is contained in:
parent
613f9f4e2b
commit
61882e5cd3
5 changed files with 711 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# V1 Block 14 — Application Use Cases and UI-Ready Read Models
|
||||
|
||||
Status: In progress
|
||||
Status: Complete
|
||||
|
||||
Purpose: Add the orchestration layer that a future Flutter UI can call without
|
||||
assembling scheduler inputs, coordinating multiple repositories, or manually
|
||||
|
|
@ -321,6 +321,8 @@ Verification:
|
|||
|
||||
Recommended Codex level: high
|
||||
|
||||
Status: Complete
|
||||
|
||||
Tasks:
|
||||
|
||||
- Add an explicit app-open/start-day use case that determines whether a source
|
||||
|
|
@ -355,6 +357,39 @@ Acceptance criteria:
|
|||
- Notice lifecycle is persisted and acknowledgeable.
|
||||
- The full application facade contract suite passes in memory.
|
||||
|
||||
Completed implementation:
|
||||
|
||||
- Added `src/application_recovery.dart` with `V1AppOpenRecoveryUseCases`,
|
||||
explicit source-day/target-day request objects, deterministic recovery
|
||||
outcomes, and structured recovery results.
|
||||
- Implemented app-open recovery as an explicit command that checks one
|
||||
owner/source-local-date key and never runs as a Today-read side effect.
|
||||
- Keyed rollover idempotency by deterministic owner/source-date scheduling
|
||||
snapshot IDs, so retries and multiple app opens do not roll the same source
|
||||
day twice even with different operation IDs.
|
||||
- Loaded source-day and target-day scheduled tasks through bounded repository
|
||||
window queries, preventing future-day tasks from being pulled into rollover.
|
||||
- Used the existing pure scheduler rollover behavior to preserve source-day
|
||||
flexible order at the top of the target day while shifting target-day flexible
|
||||
tasks as needed.
|
||||
- Persisted changed tasks, rollover movement activities, the deterministic
|
||||
scheduling snapshot, the calm aggregate notice, and the operation record in
|
||||
one unit-of-work transaction.
|
||||
- Saved no-op source days with deterministic snapshots and no notice so repeated
|
||||
app opens remain idempotent without creating duplicate pending notices.
|
||||
- Reused the Chunk 14.4 notice acknowledgement path so Today state surfaces the
|
||||
pending rollover notice until it is acknowledged.
|
||||
- Added end-to-end in-memory recovery tests covering capture, next-day app open,
|
||||
target queue insertion, notice visibility, acknowledgement, second-open
|
||||
idempotency, no-op recording, and future-day exclusion.
|
||||
|
||||
Verification:
|
||||
|
||||
- `dart format lib test`: passed, 0 files changed after final format
|
||||
- `dart analyze`: passed, no issues found
|
||||
- `dart test`: passed, 277 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
Commit suggestion:
|
||||
|
||||
```text
|
||||
|
|
@ -20,6 +20,7 @@ export 'src/models.dart';
|
|||
export 'src/application_commands.dart';
|
||||
export 'src/application_layer.dart';
|
||||
export 'src/application_management.dart';
|
||||
export 'src/application_recovery.dart';
|
||||
export 'src/backlog.dart';
|
||||
export 'src/child_tasks.dart';
|
||||
export 'src/document_mapping.dart';
|
||||
|
|
|
|||
|
|
@ -458,6 +458,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
);
|
||||
}
|
||||
|
||||
/// Current committed scheduling snapshots, useful for contract tests.
|
||||
List<SchedulingStateSnapshot> get currentSchedulingSnapshots {
|
||||
return List<SchedulingStateSnapshot>.unmodifiable(
|
||||
_state.schedulingSnapshotsById.values,
|
||||
);
|
||||
}
|
||||
|
||||
/// Current committed activities, useful for contract tests.
|
||||
List<TaskActivity> get currentTaskActivities {
|
||||
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);
|
||||
|
|
|
|||
401
lib/src/application_recovery.dart
Normal file
401
lib/src/application_recovery.dart
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
// App-open/start-day recovery orchestration.
|
||||
//
|
||||
// This layer makes rollover explicit. Reading Today never runs recovery; a UI or
|
||||
// app lifecycle boundary calls this use case when it wants to check one source
|
||||
// day and persist any resulting V1 flexible-task rollover.
|
||||
|
||||
import 'application_layer.dart';
|
||||
import 'locked_time.dart';
|
||||
import 'models.dart';
|
||||
import 'repositories.dart';
|
||||
import 'scheduling_engine.dart';
|
||||
import 'task_lifecycle.dart';
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// Stable app-open recovery outcomes.
|
||||
enum AppOpenRecoveryOutcome {
|
||||
rolledOver,
|
||||
noUnfinishedFlexibleTasks,
|
||||
alreadyProcessed,
|
||||
}
|
||||
|
||||
/// Request for explicit app-open/start-day rollover recovery.
|
||||
class RunAppOpenRecoveryRequest {
|
||||
const RunAppOpenRecoveryRequest({
|
||||
required this.context,
|
||||
required this.sourceDate,
|
||||
this.targetDate,
|
||||
});
|
||||
|
||||
/// Atomic operation context.
|
||||
final ApplicationOperationContext context;
|
||||
|
||||
/// Owner-local day to inspect for unfinished flexible work.
|
||||
final CivilDate sourceDate;
|
||||
|
||||
/// Owner-local target day. Defaults to the next local day.
|
||||
final CivilDate? targetDate;
|
||||
|
||||
/// Resolved target day.
|
||||
CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1);
|
||||
}
|
||||
|
||||
/// Result returned by app-open recovery.
|
||||
class AppOpenRecoveryResult {
|
||||
AppOpenRecoveryResult({
|
||||
required this.outcome,
|
||||
required this.sourceDate,
|
||||
required this.targetDate,
|
||||
required this.snapshot,
|
||||
required List<Task> changedTasks,
|
||||
required List<TaskActivity> activities,
|
||||
required List<SchedulingNotice> notices,
|
||||
required List<SchedulingChange> changes,
|
||||
}) : changedTasks = List<Task>.unmodifiable(changedTasks),
|
||||
activities = List<TaskActivity>.unmodifiable(activities),
|
||||
notices = List<SchedulingNotice>.unmodifiable(notices),
|
||||
changes = List<SchedulingChange>.unmodifiable(changes);
|
||||
|
||||
/// High-level deterministic outcome.
|
||||
final AppOpenRecoveryOutcome outcome;
|
||||
|
||||
/// Owner-local source day that was checked.
|
||||
final CivilDate sourceDate;
|
||||
|
||||
/// Owner-local target day used for placement and notice surfacing.
|
||||
final CivilDate targetDate;
|
||||
|
||||
/// Persisted deterministic source-date snapshot.
|
||||
final SchedulingStateSnapshot snapshot;
|
||||
|
||||
/// Tasks changed by this operation.
|
||||
final List<Task> changedTasks;
|
||||
|
||||
/// Movement activities persisted with changed tasks.
|
||||
final List<TaskActivity> activities;
|
||||
|
||||
/// Notices returned to the caller.
|
||||
final List<SchedulingNotice> notices;
|
||||
|
||||
/// Machine-readable movement records.
|
||||
final List<SchedulingChange> changes;
|
||||
}
|
||||
|
||||
/// Explicit app-open recovery facade.
|
||||
class V1AppOpenRecoveryUseCases {
|
||||
const V1AppOpenRecoveryUseCases({
|
||||
required this.applicationStore,
|
||||
required this.timeZoneResolver,
|
||||
this.resolutionOptions = const TimeZoneResolutionOptions(),
|
||||
this.schedulingEngine = const SchedulingEngine(),
|
||||
});
|
||||
|
||||
/// Atomic application boundary.
|
||||
final ApplicationUnitOfWork applicationStore;
|
||||
|
||||
/// Explicit owner-local date resolver.
|
||||
final TimeZoneResolver timeZoneResolver;
|
||||
|
||||
/// DST/nonexistent/repeated local time policy.
|
||||
final TimeZoneResolutionOptions resolutionOptions;
|
||||
|
||||
/// Pure scheduling engine.
|
||||
final SchedulingEngine schedulingEngine;
|
||||
|
||||
/// Run source-day rollover at most once for one owner/source-date key.
|
||||
Future<ApplicationResult<AppOpenRecoveryResult>> runAppOpenRecovery(
|
||||
RunAppOpenRecoveryRequest request,
|
||||
) {
|
||||
return applicationStore.run(
|
||||
context: request.context,
|
||||
operationName: 'appOpenRecovery',
|
||||
action: (repositories) async {
|
||||
final context = request.context;
|
||||
final ownerId = context.ownerTimeZone.ownerId;
|
||||
final sourceDate = request.sourceDate;
|
||||
final targetDate = request.resolvedTargetDate;
|
||||
final snapshotId = _rolloverSnapshotId(
|
||||
ownerId: ownerId,
|
||||
sourceDate: sourceDate,
|
||||
);
|
||||
final existingSnapshot =
|
||||
await repositories.schedulingSnapshots.findById(snapshotId);
|
||||
if (existingSnapshot != null) {
|
||||
return ApplicationResult.success(
|
||||
AppOpenRecoveryResult(
|
||||
outcome: AppOpenRecoveryOutcome.alreadyProcessed,
|
||||
sourceDate: sourceDate,
|
||||
targetDate: targetDate,
|
||||
snapshot: existingSnapshot,
|
||||
changedTasks: const <Task>[],
|
||||
activities: const <TaskActivity>[],
|
||||
notices: existingSnapshot.notices,
|
||||
changes: existingSnapshot.changes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final settings = await _ownerSettingsOrDefault(
|
||||
repositories,
|
||||
context.ownerTimeZone,
|
||||
);
|
||||
final sourceWindow = _windowForDate(sourceDate, settings);
|
||||
final targetWindow = _windowForDate(targetDate, settings);
|
||||
final tasks = await _loadSourceAndTargetTasks(
|
||||
repositories,
|
||||
sourceWindow: sourceWindow,
|
||||
targetWindow: targetWindow,
|
||||
);
|
||||
final blocks = await repositories.lockedBlocks.findAllBlocks();
|
||||
final overrides = await repositories.lockedBlocks.findAllOverrides();
|
||||
final lockedExpansion = expandLockedBlocksForDay(
|
||||
blocks: blocks,
|
||||
overrides: overrides,
|
||||
date: targetDate,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
);
|
||||
final input = SchedulingInput(
|
||||
tasks: tasks,
|
||||
window: targetWindow,
|
||||
lockedIntervals: lockedExpansion.schedulingIntervals,
|
||||
);
|
||||
final schedulingResult =
|
||||
schedulingEngine.rollOverUnfinishedFlexibleTasks(
|
||||
input: input,
|
||||
sourceWindow: sourceWindow,
|
||||
updatedAt: context.now,
|
||||
);
|
||||
|
||||
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
|
||||
schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) {
|
||||
return ApplicationResult.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.noSlot,
|
||||
entityId: snapshotId,
|
||||
detailCode: schedulingResult.outcomeCode.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final changedTasks = _changedTasks(tasks, schedulingResult.tasks);
|
||||
final activities = _activitiesForRolloverChanges(
|
||||
result: schedulingResult,
|
||||
beforeTasks: tasks,
|
||||
afterTasks: schedulingResult.tasks,
|
||||
operationId: context.operationId,
|
||||
occurredAt: context.now,
|
||||
);
|
||||
final rolloverNotice = _rolloverNotice(
|
||||
sourceWindow: sourceWindow,
|
||||
result: schedulingResult,
|
||||
);
|
||||
final snapshot = SchedulingStateSnapshot(
|
||||
id: snapshotId,
|
||||
capturedAt: context.now,
|
||||
window: targetWindow,
|
||||
tasks: schedulingResult.tasks,
|
||||
lockedIntervals: input.lockedIntervals,
|
||||
notices: [
|
||||
if (rolloverNotice != null) rolloverNotice,
|
||||
],
|
||||
changes: schedulingResult.changes,
|
||||
operationName: 'appOpenRecovery',
|
||||
);
|
||||
|
||||
await repositories.tasks.saveAll(changedTasks);
|
||||
await repositories.taskActivities.saveAll(activities);
|
||||
await repositories.schedulingSnapshots.save(snapshot);
|
||||
|
||||
return ApplicationResult.success(
|
||||
AppOpenRecoveryResult(
|
||||
outcome: rolloverNotice == null
|
||||
? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks
|
||||
: AppOpenRecoveryOutcome.rolledOver,
|
||||
sourceDate: sourceDate,
|
||||
targetDate: targetDate,
|
||||
snapshot: snapshot,
|
||||
changedTasks: changedTasks,
|
||||
activities: activities,
|
||||
notices: snapshot.notices,
|
||||
changes: schedulingResult.changes,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) {
|
||||
return SchedulingWindow(
|
||||
start: _resolve(
|
||||
date: date,
|
||||
wallTime: settings.dayStart,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
),
|
||||
end: _resolve(
|
||||
date: date,
|
||||
wallTime: settings.dayEnd,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _resolve({
|
||||
required CivilDate date,
|
||||
required WallTime wallTime,
|
||||
required String timeZoneId,
|
||||
}) {
|
||||
return timeZoneResolver
|
||||
.resolve(
|
||||
date: date,
|
||||
wallTime: wallTime,
|
||||
timeZoneId: timeZoneId,
|
||||
options: resolutionOptions,
|
||||
)
|
||||
.instant;
|
||||
}
|
||||
}
|
||||
|
||||
Future<OwnerSettings> _ownerSettingsOrDefault(
|
||||
ApplicationUnitOfWorkRepositories repositories,
|
||||
OwnerTimeZoneContext ownerTimeZone,
|
||||
) async {
|
||||
return await repositories.ownerSettings
|
||||
.findByOwnerId(ownerTimeZone.ownerId) ??
|
||||
OwnerSettings(
|
||||
ownerId: ownerTimeZone.ownerId,
|
||||
timeZoneId: ownerTimeZone.timeZoneId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Task>> _loadSourceAndTargetTasks(
|
||||
ApplicationUnitOfWorkRepositories repositories, {
|
||||
required SchedulingWindow sourceWindow,
|
||||
required SchedulingWindow targetWindow,
|
||||
}) async {
|
||||
final byId = <String, Task>{};
|
||||
for (final task in await repositories.tasks.findScheduledInWindow(
|
||||
sourceWindow,
|
||||
)) {
|
||||
byId[task.id] = task;
|
||||
}
|
||||
for (final task in await repositories.tasks.findScheduledInWindow(
|
||||
targetWindow,
|
||||
)) {
|
||||
byId[task.id] = task;
|
||||
}
|
||||
return List<Task>.unmodifiable(byId.values);
|
||||
}
|
||||
|
||||
SchedulingNotice? _rolloverNotice({
|
||||
required SchedulingWindow sourceWindow,
|
||||
required SchedulingResult result,
|
||||
}) {
|
||||
if (result.outcomeCode != SchedulingOutcomeCode.success) {
|
||||
return null;
|
||||
}
|
||||
final rolledTaskIds = result.changes
|
||||
.where(
|
||||
(change) =>
|
||||
change.previousStart != null &&
|
||||
change.previousEnd != null &&
|
||||
sourceWindow.contains(
|
||||
TimeInterval(
|
||||
start: change.previousStart!, end: change.previousEnd!),
|
||||
),
|
||||
)
|
||||
.map((change) => change.taskId)
|
||||
.toSet();
|
||||
if (rolledTaskIds.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SchedulingNotice(
|
||||
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.',
|
||||
type: SchedulingNoticeType.moved,
|
||||
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
|
||||
parameters: {
|
||||
'count': rolledTaskIds.length,
|
||||
'taskIds': List<String>.unmodifiable(rolledTaskIds),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<TaskActivity> _activitiesForRolloverChanges({
|
||||
required SchedulingResult result,
|
||||
required List<Task> beforeTasks,
|
||||
required List<Task> afterTasks,
|
||||
required String operationId,
|
||||
required DateTime occurredAt,
|
||||
}) {
|
||||
if (result.outcomeCode != SchedulingOutcomeCode.success) {
|
||||
return const <TaskActivity>[];
|
||||
}
|
||||
final beforeById = {
|
||||
for (final task in beforeTasks) task.id: task,
|
||||
};
|
||||
final afterById = {
|
||||
for (final task in afterTasks) task.id: task,
|
||||
};
|
||||
final activities = <TaskActivity>[];
|
||||
|
||||
for (final change in result.changes) {
|
||||
final before = beforeById[change.taskId];
|
||||
final after = afterById[change.taskId];
|
||||
if (before == null || after == null) {
|
||||
continue;
|
||||
}
|
||||
activities.add(
|
||||
TaskActivity(
|
||||
id: '$operationId:${change.taskId}:automaticPush',
|
||||
operationId: operationId,
|
||||
code: TaskActivityCode.automaticallyPushed,
|
||||
taskId: change.taskId,
|
||||
projectId: before.projectId,
|
||||
occurredAt: occurredAt,
|
||||
metadata: {
|
||||
'previousStatus': before.status.name,
|
||||
'nextStatus': after.status.name,
|
||||
'previousStart': change.previousStart,
|
||||
'previousEnd': change.previousEnd,
|
||||
'nextStart': change.nextStart,
|
||||
'nextEnd': change.nextEnd,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return List<TaskActivity>.unmodifiable(activities);
|
||||
}
|
||||
|
||||
List<Task> _changedTasks(List<Task> beforeTasks, List<Task> afterTasks) {
|
||||
final beforeById = {
|
||||
for (final task in beforeTasks) task.id: task,
|
||||
};
|
||||
final changed = <Task>[];
|
||||
|
||||
for (final task in afterTasks) {
|
||||
final before = beforeById[task.id];
|
||||
if (before == null || _taskChanged(before, task)) {
|
||||
changed.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
return List<Task>.unmodifiable(changed);
|
||||
}
|
||||
|
||||
bool _taskChanged(Task before, Task after) {
|
||||
return before.status != after.status ||
|
||||
before.scheduledStart != after.scheduledStart ||
|
||||
before.scheduledEnd != after.scheduledEnd ||
|
||||
before.updatedAt != after.updatedAt ||
|
||||
before.stats != after.stats;
|
||||
}
|
||||
|
||||
String _rolloverSnapshotId({
|
||||
required String ownerId,
|
||||
required CivilDate sourceDate,
|
||||
}) {
|
||||
return 'rollover:$ownerId:${sourceDate.toIsoString()}';
|
||||
}
|
||||
266
test/application_recovery_test.dart
Normal file
266
test/application_recovery_test.dart
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('V1AppOpenRecoveryUseCases', () {
|
||||
final sourceDay = CivilDate(2026, 6, 25);
|
||||
final targetDay = CivilDate(2026, 6, 26);
|
||||
final createdAt = DateTime.utc(2026, 6, 20, 8);
|
||||
|
||||
test('rolls source-day flexible work once and exposes notice lifecycle',
|
||||
() async {
|
||||
final targetExisting = task(
|
||||
id: 'target-existing',
|
||||
title: 'Target existing',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 26, 9),
|
||||
end: DateTime.utc(2026, 6, 26, 9, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [targetExisting]);
|
||||
final commandUseCases = commands(store);
|
||||
final recoveryUseCases = recovery(store);
|
||||
final managementUseCases =
|
||||
V1ApplicationManagementUseCases(applicationStore: store);
|
||||
final todayQuery = today(store);
|
||||
|
||||
final capture = await commandUseCases.quickCaptureToNextAvailableSlot(
|
||||
context: appContext('capture-source', DateTime.utc(2026, 6, 25, 8)),
|
||||
localDate: sourceDay,
|
||||
title: 'Captured source task',
|
||||
durationMinutes: 30,
|
||||
);
|
||||
expect(capture.isSuccess, isTrue);
|
||||
final capturedTaskId = capture.requireValue.changedTasks.single.id;
|
||||
|
||||
final firstOpen = await recoveryUseCases.runAppOpenRecovery(
|
||||
RunAppOpenRecoveryRequest(
|
||||
context: appContext('open-next-day', DateTime.utc(2026, 6, 26, 8)),
|
||||
sourceDate: sourceDay,
|
||||
targetDate: targetDay,
|
||||
),
|
||||
);
|
||||
|
||||
expect(firstOpen.requireValue.outcome, AppOpenRecoveryOutcome.rolledOver);
|
||||
expect(firstOpen.requireValue.changedTasks.map((task) => task.id), [
|
||||
capturedTaskId,
|
||||
'target-existing',
|
||||
]);
|
||||
expect(taskById(store.currentTasks, capturedTaskId).status,
|
||||
TaskStatus.planned);
|
||||
expect(taskById(store.currentTasks, capturedTaskId).scheduledStart,
|
||||
DateTime.utc(2026, 6, 26, 9));
|
||||
expect(taskById(store.currentTasks, 'target-existing').scheduledStart,
|
||||
DateTime.utc(2026, 6, 26, 9, 30));
|
||||
expect(
|
||||
store.currentSchedulingSnapshots.single.notices.single.movementCode,
|
||||
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver);
|
||||
expect(
|
||||
store.currentSchedulingSnapshots.single.notices.single
|
||||
.parameters['count'],
|
||||
1);
|
||||
|
||||
final todayBeforeAck = (await todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
context: appContext('today-before-ack', DateTime.utc(2026, 6, 26, 8)),
|
||||
date: targetDay,
|
||||
),
|
||||
))
|
||||
.requireValue;
|
||||
expect(todayBeforeAck.pendingNotices.single.parameters['count'], 1);
|
||||
|
||||
await managementUseCases.acknowledgeNotice(
|
||||
context: appContext('ack-rollover', DateTime.utc(2026, 6, 26, 8, 5)),
|
||||
noticeId: todayBeforeAck.pendingNotices.single.noticeId,
|
||||
);
|
||||
final todayAfterAck = (await todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
context:
|
||||
appContext('today-after-ack', DateTime.utc(2026, 6, 26, 8, 6)),
|
||||
date: targetDay,
|
||||
),
|
||||
))
|
||||
.requireValue;
|
||||
final secondOpen = await recoveryUseCases.runAppOpenRecovery(
|
||||
RunAppOpenRecoveryRequest(
|
||||
context:
|
||||
appContext('open-next-day-again', DateTime.utc(2026, 6, 26, 9)),
|
||||
sourceDate: sourceDay,
|
||||
targetDate: targetDay,
|
||||
),
|
||||
);
|
||||
|
||||
expect(todayAfterAck.pendingNotices, isEmpty);
|
||||
expect(
|
||||
secondOpen.requireValue.outcome,
|
||||
AppOpenRecoveryOutcome.alreadyProcessed,
|
||||
);
|
||||
expect(secondOpen.requireValue.changedTasks, isEmpty);
|
||||
expect(store.currentSchedulingSnapshots, hasLength(1));
|
||||
expect(
|
||||
store.currentOperations
|
||||
.map((operation) => operation.operationId)
|
||||
.toSet(),
|
||||
containsAll({
|
||||
'capture-source',
|
||||
'open-next-day',
|
||||
'ack-rollover',
|
||||
'open-next-day-again',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('records deterministic no-op without creating a notice', () async {
|
||||
final completed = task(
|
||||
id: 'done',
|
||||
title: 'Done',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.completed,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 10),
|
||||
end: DateTime.utc(2026, 6, 25, 10, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [completed]);
|
||||
|
||||
final result = await recovery(store).runAppOpenRecovery(
|
||||
RunAppOpenRecoveryRequest(
|
||||
context: appContext('open-noop', DateTime.utc(2026, 6, 26, 8)),
|
||||
sourceDate: sourceDay,
|
||||
targetDate: targetDay,
|
||||
),
|
||||
);
|
||||
final duplicate = await recovery(store).runAppOpenRecovery(
|
||||
RunAppOpenRecoveryRequest(
|
||||
context: appContext('open-noop-again', DateTime.utc(2026, 6, 26, 9)),
|
||||
sourceDate: sourceDay,
|
||||
targetDate: targetDay,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.requireValue.outcome,
|
||||
AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks,
|
||||
);
|
||||
expect(result.requireValue.notices, isEmpty);
|
||||
expect(store.currentSchedulingSnapshots.single.notices, isEmpty);
|
||||
expect(duplicate.requireValue.outcome,
|
||||
AppOpenRecoveryOutcome.alreadyProcessed);
|
||||
expect(store.currentSchedulingSnapshots, hasLength(1));
|
||||
});
|
||||
|
||||
test('does not pull future-day tasks into source-day rollover', () async {
|
||||
final sourceTask = task(
|
||||
id: 'source',
|
||||
title: 'Source',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 25, 10),
|
||||
end: DateTime.utc(2026, 6, 25, 10, 30),
|
||||
);
|
||||
final futureTask = task(
|
||||
id: 'future',
|
||||
title: 'Future',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
createdAt: createdAt,
|
||||
start: DateTime.utc(2026, 6, 27, 9),
|
||||
end: DateTime.utc(2026, 6, 27, 9, 30),
|
||||
);
|
||||
final store = storeWithSettings(tasks: [sourceTask, futureTask]);
|
||||
|
||||
final result = await recovery(store).runAppOpenRecovery(
|
||||
RunAppOpenRecoveryRequest(
|
||||
context: appContext('open-future-safe', DateTime.utc(2026, 6, 26, 8)),
|
||||
sourceDate: sourceDay,
|
||||
targetDate: targetDay,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(taskById(store.currentTasks, sourceTask.id).scheduledStart,
|
||||
DateTime.utc(2026, 6, 26, 9));
|
||||
expect(taskById(store.currentTasks, futureTask.id).scheduledStart,
|
||||
DateTime.utc(2026, 6, 27, 9));
|
||||
expect(
|
||||
result.requireValue.changedTasks.map((task) => task.id),
|
||||
isNot(contains('future')),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) {
|
||||
return V1ApplicationCommandUseCases(
|
||||
applicationStore: store,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
);
|
||||
}
|
||||
|
||||
V1AppOpenRecoveryUseCases recovery(InMemoryApplicationUnitOfWork store) {
|
||||
return V1AppOpenRecoveryUseCases(
|
||||
applicationStore: store,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
);
|
||||
}
|
||||
|
||||
GetTodayStateQuery today(InMemoryApplicationUnitOfWork store) {
|
||||
return GetTodayStateQuery(
|
||||
applicationStore: store,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
);
|
||||
}
|
||||
|
||||
InMemoryApplicationUnitOfWork storeWithSettings({
|
||||
List<Task> tasks = const <Task>[],
|
||||
}) {
|
||||
return InMemoryApplicationUnitOfWork(
|
||||
initialTasks: tasks,
|
||||
initialOwnerSettings: [
|
||||
OwnerSettings(
|
||||
ownerId: 'owner-1',
|
||||
timeZoneId: 'UTC',
|
||||
dayStart: WallTime(hour: 9, minute: 0),
|
||||
dayEnd: WallTime(hour: 17, minute: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
ApplicationOperationContext appContext(String operationId, DateTime now) {
|
||||
return ApplicationOperationContext.start(
|
||||
operationId: operationId,
|
||||
ownerTimeZone: OwnerTimeZoneContext(ownerId: 'owner-1', timeZoneId: 'UTC'),
|
||||
clock: FixedClock(now),
|
||||
idGenerator: SequentialIdGenerator(prefix: '$operationId-id'),
|
||||
);
|
||||
}
|
||||
|
||||
Task task({
|
||||
required String id,
|
||||
required String title,
|
||||
required TaskType type,
|
||||
required TaskStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
}) {
|
||||
return Task(
|
||||
id: id,
|
||||
title: title,
|
||||
projectId: 'home',
|
||||
type: type,
|
||||
status: status,
|
||||
durationMinutes:
|
||||
start != null && end != null ? end.difference(start).inMinutes : 30,
|
||||
scheduledStart: start,
|
||||
scheduledEnd: end,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
Task taskById(List<Task> tasks, String taskId) {
|
||||
return tasks.singleWhere((task) => task.id == taskId);
|
||||
}
|
||||
Loading…
Reference in a new issue