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
|
# 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
|
Purpose: Add the orchestration layer that a future Flutter UI can call without
|
||||||
assembling scheduler inputs, coordinating multiple repositories, or manually
|
assembling scheduler inputs, coordinating multiple repositories, or manually
|
||||||
|
|
@ -321,6 +321,8 @@ Verification:
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Complete
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Add an explicit app-open/start-day use case that determines whether a source
|
- 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.
|
- Notice lifecycle is persisted and acknowledgeable.
|
||||||
- The full application facade contract suite passes in memory.
|
- 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:
|
Commit suggestion:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -20,6 +20,7 @@ export 'src/models.dart';
|
||||||
export 'src/application_commands.dart';
|
export 'src/application_commands.dart';
|
||||||
export 'src/application_layer.dart';
|
export 'src/application_layer.dart';
|
||||||
export 'src/application_management.dart';
|
export 'src/application_management.dart';
|
||||||
|
export 'src/application_recovery.dart';
|
||||||
export 'src/backlog.dart';
|
export 'src/backlog.dart';
|
||||||
export 'src/child_tasks.dart';
|
export 'src/child_tasks.dart';
|
||||||
export 'src/document_mapping.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.
|
/// Current committed activities, useful for contract tests.
|
||||||
List<TaskActivity> get currentTaskActivities {
|
List<TaskActivity> get currentTaskActivities {
|
||||||
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);
|
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