test(data): add MongoDB persistence edge-case regressions

This commit is contained in:
Ashley Venn 2026-06-24 14:11:48 -07:00
parent 305090250a
commit 9537e06c3c
7 changed files with 684 additions and 2 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 09 — Persistence Preparation # V1 Block 09 — Persistence Preparation
Status: In progress — Chunk 9.1 complete Status: In progress — Chunks 9.1 and 9.2 complete
Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early. Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early.
@ -84,6 +84,21 @@ Acceptance criteria:
- Any unresolved persistence detail is documented as a TODO in the plan or architecture notes. - Any unresolved persistence detail is documented as a TODO in the plan or architecture notes.
- No alternative-database references remain in the active persistence plan. - No alternative-database references remain in the active persistence plan.
Completed:
- Added persistence edge-case regression tests for stable task, project, and locked-block IDs across copy/update helpers.
- Added UTC ISO-8601 DateTime persistence convention helpers and tests.
- Added enum persistence-name mapping tests, including `RewardLevel.notSet` remaining distinct from `RewardLevel.veryLow`.
- Added nullable-field preservation and explicit schedule-clearing tests.
- Added backlog age source tests documenting `Task.createdAt` as the current staleness source.
- Added in-memory repository round-trip and unmodifiable-read tests.
- Added MongoDB-oriented document field-name constants and tests for task/statistics, project, locked-block, override, scheduling snapshot, and embedded scheduling structures.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
TODO for Chunk 9.3:
- Define document mapping behavior for clearing nullable non-schedule fields before adding model serialization helpers. Current explicit clear behavior only covers task schedule placement through `Task.copyWith(clearSchedule: true)`.
BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes. BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes.
## Chunk 9.3 — MongoDB-document-safe models ## Chunk 9.3 — MongoDB-document-safe models

View file

@ -1,6 +1,6 @@
# V1 Block 09 — Persistence Preparation # V1 Block 09 — Persistence Preparation
Status: In progress — Chunk 9.1 complete Status: In progress — Chunks 9.1 and 9.2 complete
Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early. Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early.
@ -84,6 +84,21 @@ Acceptance criteria:
- Any unresolved persistence detail is documented as a TODO in the plan or architecture notes. - Any unresolved persistence detail is documented as a TODO in the plan or architecture notes.
- No alternative-database references remain in the active persistence plan. - No alternative-database references remain in the active persistence plan.
Completed:
- Added persistence edge-case regression tests for stable task, project, and locked-block IDs across copy/update helpers.
- Added UTC ISO-8601 DateTime persistence convention helpers and tests.
- Added enum persistence-name mapping tests, including `RewardLevel.notSet` remaining distinct from `RewardLevel.veryLow`.
- Added nullable-field preservation and explicit schedule-clearing tests.
- Added backlog age source tests documenting `Task.createdAt` as the current staleness source.
- Added in-memory repository round-trip and unmodifiable-read tests.
- Added MongoDB-oriented document field-name constants and tests for task/statistics, project, locked-block, override, scheduling snapshot, and embedded scheduling structures.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
TODO for Chunk 9.3:
- Define document mapping behavior for clearing nullable non-schedule fields before adding model serialization helpers. Current explicit clear behavior only covers task schedule placement through `Task.copyWith(clearSchedule: true)`.
BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes. BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes.
## Chunk 9.3 — MongoDB-document-safe models ## Chunk 9.3 — MongoDB-document-safe models

View file

@ -20,6 +20,7 @@ export 'src/models.dart';
export 'src/backlog.dart'; export 'src/backlog.dart';
export 'src/child_tasks.dart'; export 'src/child_tasks.dart';
export 'src/locked_time.dart'; export 'src/locked_time.dart';
export 'src/persistence_contract.dart';
export 'src/quick_capture.dart'; export 'src/quick_capture.dart';
export 'src/repositories.dart'; export 'src/repositories.dart';
export 'src/scheduling_engine.dart'; export 'src/scheduling_engine.dart';

View file

@ -136,6 +136,33 @@ class LockedBlock {
/// Convenience check for whether this block expands through recurrence. /// Convenience check for whether this block expands through recurrence.
bool get isRecurring => recurrence != null; bool get isRecurring => recurrence != null;
/// Return a copy with selected locked-block details changed.
LockedBlock copyWith({
String? id,
String? name,
ClockTime? startTime,
ClockTime? endTime,
DateTime? date,
LockedBlockRecurrence? recurrence,
bool? hiddenByDefault,
String? projectId,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return LockedBlock(
id: id ?? this.id,
name: name ?? this.name,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
date: date ?? this.date,
recurrence: recurrence ?? this.recurrence,
hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault,
projectId: projectId ?? this.projectId,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
} }
/// Concrete locked-time occurrence for one calendar day. /// Concrete locked-time occurrence for one calendar day.

View file

@ -464,6 +464,31 @@ class ProjectProfile {
updatedAt: updatedAt ?? createdAt, updatedAt: updatedAt ?? createdAt,
); );
} }
/// Return a copy with selected project defaults changed.
ProjectProfile copyWith({
String? id,
String? name,
String? colorKey,
PriorityLevel? defaultPriority,
RewardLevel? defaultReward,
DifficultyLevel? defaultDifficulty,
ReminderProfile? defaultReminderProfile,
int? defaultDurationMinutes,
}) {
return ProjectProfile(
id: id ?? this.id,
name: name ?? this.name,
colorKey: colorKey ?? this.colorKey,
defaultPriority: defaultPriority ?? this.defaultPriority,
defaultReward: defaultReward ?? this.defaultReward,
defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty,
defaultReminderProfile:
defaultReminderProfile ?? this.defaultReminderProfile,
defaultDurationMinutes:
defaultDurationMinutes ?? this.defaultDurationMinutes,
);
}
} }
/// Starter time range value used by scheduling helpers. /// Starter time range value used by scheduling helpers.

View file

@ -0,0 +1,319 @@
// MongoDB-oriented persistence contract helpers.
//
// This file defines stable names and conventions that future document mapping
// code should use. It deliberately does not serialize domain models or import a
// MongoDB client.
/// DateTime storage convention for future document persistence.
abstract final class PersistenceDateTimeConvention {
/// Human-readable convention kept close to the helper for tests and docs.
static const description =
'Store DateTime values as UTC ISO-8601 strings and compare as UTC instants.';
/// Convert a DateTime into the persisted string convention.
static String toStoredString(DateTime value) {
return value.toUtc().toIso8601String();
}
/// Parse a persisted DateTime string back into a UTC DateTime.
static DateTime fromStoredString(String value) {
return DateTime.parse(value).toUtc();
}
/// Compare two DateTime values by their UTC instant.
static int compare(DateTime left, DateTime right) {
return left.toUtc().compareTo(right.toUtc());
}
}
/// Stable enum-name mapping plan for future document persistence.
extension PersistenceEnumName on Enum {
/// Persist enum values by their Dart enum name.
String get persistenceName => name;
}
/// Shared MongoDB document field names.
abstract final class DocumentFields {
/// MongoDB primary id field.
static const id = '_id';
}
/// Document field names for [Task] documents.
abstract final class TaskDocumentFields {
static const id = DocumentFields.id;
static const title = 'title';
static const projectId = 'projectId';
static const type = 'type';
static const status = 'status';
static const priority = 'priority';
static const reward = 'reward';
static const difficulty = 'difficulty';
static const durationMinutes = 'durationMinutes';
static const scheduledStart = 'scheduledStart';
static const scheduledEnd = 'scheduledEnd';
static const parentTaskId = 'parentTaskId';
static const backlogTags = 'backlogTags';
static const createdAt = 'createdAt';
static const updatedAt = 'updatedAt';
static const stats = 'stats';
static const all = <String>{
id,
title,
projectId,
type,
status,
priority,
reward,
difficulty,
durationMinutes,
scheduledStart,
scheduledEnd,
parentTaskId,
backlogTags,
createdAt,
updatedAt,
stats,
};
}
/// Document field names for [TaskStatistics] embedded documents.
abstract final class TaskStatisticsDocumentFields {
static const skippedDuringBurnoutCount = 'skippedDuringBurnoutCount';
static const manuallyPushedCount = 'manuallyPushedCount';
static const autoPushedCount = 'autoPushedCount';
static const movedToBacklogCount = 'movedToBacklogCount';
static const restoredFromBacklogCount = 'restoredFromBacklogCount';
static const missedCount = 'missedCount';
static const cancelledCount = 'cancelledCount';
static const completedLateCount = 'completedLateCount';
static const completedDuringLockedHoursCount =
'completedDuringLockedHoursCount';
static const completedDuringLockedHoursMinutes =
'completedDuringLockedHoursMinutes';
static const all = <String>{
skippedDuringBurnoutCount,
manuallyPushedCount,
autoPushedCount,
movedToBacklogCount,
restoredFromBacklogCount,
missedCount,
cancelledCount,
completedLateCount,
completedDuringLockedHoursCount,
completedDuringLockedHoursMinutes,
};
}
/// Document field names for [ProjectProfile] documents.
abstract final class ProjectDocumentFields {
static const id = DocumentFields.id;
static const name = 'name';
static const colorKey = 'colorKey';
static const defaultPriority = 'defaultPriority';
static const defaultReward = 'defaultReward';
static const defaultDifficulty = 'defaultDifficulty';
static const defaultReminderProfile = 'defaultReminderProfile';
static const defaultDurationMinutes = 'defaultDurationMinutes';
static const all = <String>{
id,
name,
colorKey,
defaultPriority,
defaultReward,
defaultDifficulty,
defaultReminderProfile,
defaultDurationMinutes,
};
}
/// Document field names for [ClockTime] embedded documents.
abstract final class ClockTimeDocumentFields {
static const hour = 'hour';
static const minute = 'minute';
static const all = <String>{
hour,
minute,
};
}
/// Document field names for [LockedBlockRecurrence] embedded documents.
abstract final class LockedBlockRecurrenceDocumentFields {
static const weekdays = 'weekdays';
static const all = <String>{
weekdays,
};
}
/// Document field names for [LockedBlock] documents.
abstract final class LockedBlockDocumentFields {
static const id = DocumentFields.id;
static const name = 'name';
static const startTime = 'startTime';
static const endTime = 'endTime';
static const date = 'date';
static const recurrence = 'recurrence';
static const hiddenByDefault = 'hiddenByDefault';
static const projectId = 'projectId';
static const createdAt = 'createdAt';
static const updatedAt = 'updatedAt';
static const all = <String>{
id,
name,
startTime,
endTime,
date,
recurrence,
hiddenByDefault,
projectId,
createdAt,
updatedAt,
};
}
/// Document field names for [LockedBlockOverride] documents.
abstract final class LockedBlockOverrideDocumentFields {
static const id = DocumentFields.id;
static const lockedBlockId = 'lockedBlockId';
static const date = 'date';
static const type = 'type';
static const name = 'name';
static const startTime = 'startTime';
static const endTime = 'endTime';
static const hiddenByDefault = 'hiddenByDefault';
static const projectId = 'projectId';
static const createdAt = 'createdAt';
static const updatedAt = 'updatedAt';
static const all = <String>{
id,
lockedBlockId,
date,
type,
name,
startTime,
endTime,
hiddenByDefault,
projectId,
createdAt,
updatedAt,
};
}
/// Document field names for [SchedulingStateSnapshot] documents.
abstract final class SchedulingSnapshotDocumentFields {
static const id = DocumentFields.id;
static const capturedAt = 'capturedAt';
static const window = 'window';
static const tasks = 'tasks';
static const lockedIntervals = 'lockedIntervals';
static const requiredVisibleIntervals = 'requiredVisibleIntervals';
static const notices = 'notices';
static const changes = 'changes';
static const overlaps = 'overlaps';
static const operationName = 'operationName';
static const all = <String>{
id,
capturedAt,
window,
tasks,
lockedIntervals,
requiredVisibleIntervals,
notices,
changes,
overlaps,
operationName,
};
}
/// Document field names for [SchedulingWindow] embedded documents.
abstract final class SchedulingWindowDocumentFields {
static const start = 'start';
static const end = 'end';
static const all = <String>{
start,
end,
};
}
/// Document field names for [TimeInterval] embedded documents.
abstract final class TimeIntervalDocumentFields {
static const start = 'start';
static const end = 'end';
static const label = 'label';
static const all = <String>{
start,
end,
label,
};
}
/// Document field names for [SchedulingNotice] embedded documents.
abstract final class SchedulingNoticeDocumentFields {
static const message = 'message';
static const type = 'type';
static const taskId = 'taskId';
static const all = <String>{
message,
type,
taskId,
};
}
/// Document field names for [SchedulingChange] embedded documents.
abstract final class SchedulingChangeDocumentFields {
static const taskId = 'taskId';
static const previousStart = 'previousStart';
static const previousEnd = 'previousEnd';
static const nextStart = 'nextStart';
static const nextEnd = 'nextEnd';
static const all = <String>{
taskId,
previousStart,
previousEnd,
nextStart,
nextEnd,
};
}
/// Document field names for [SchedulingOverlap] embedded documents.
abstract final class SchedulingOverlapDocumentFields {
static const taskId = 'taskId';
static const taskInterval = 'taskInterval';
static const blockedInterval = 'blockedInterval';
static const all = <String>{
taskId,
taskInterval,
blockedInterval,
};
}
/// Every field-name set currently committed for future document mapping.
abstract final class PersistenceDocumentFieldSets {
static const all = <Set<String>>[
TaskDocumentFields.all,
TaskStatisticsDocumentFields.all,
ProjectDocumentFields.all,
ClockTimeDocumentFields.all,
LockedBlockRecurrenceDocumentFields.all,
LockedBlockDocumentFields.all,
LockedBlockOverrideDocumentFields.all,
SchedulingSnapshotDocumentFields.all,
SchedulingWindowDocumentFields.all,
TimeIntervalDocumentFields.all,
SchedulingNoticeDocumentFields.all,
SchedulingChangeDocumentFields.all,
SchedulingOverlapDocumentFields.all,
];
}

View file

@ -0,0 +1,280 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Persistence edge-case regressions', () {
final createdAt = DateTime.utc(2026, 6, 22, 8);
test('task ids remain stable across copy and update operations', () {
final original = scheduledTask(
id: 'task-1',
createdAt: createdAt,
);
final updated = original.copyWith(
title: 'Updated title',
status: TaskStatus.active,
updatedAt: createdAt.add(const Duration(minutes: 5)),
);
expect(updated.id, original.id);
expect(updated.title, 'Updated title');
expect(updated.status, TaskStatus.active);
});
test('project ids remain stable across copy and update operations', () {
const original = ProjectProfile(
id: 'project-1',
name: 'Project',
colorKey: 'project-blue',
);
final updated = original.copyWith(
name: 'Renamed project',
colorKey: 'project-green',
);
expect(updated.id, original.id);
expect(updated.name, 'Renamed project');
expect(updated.colorKey, 'project-green');
});
test('locked block ids remain stable across copy and update operations',
() {
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(
weekdays: {LockedWeekday.monday},
),
createdAt: createdAt,
updatedAt: createdAt,
);
final updated = original.copyWith(
name: 'Adjusted work',
hiddenByDefault: false,
updatedAt: createdAt.add(const Duration(minutes: 10)),
);
expect(updated.id, original.id);
expect(updated.name, 'Adjusted work');
expect(updated.hiddenByDefault, isFalse);
});
test('DateTime persistence uses UTC ISO-8601 strings and UTC comparison',
() {
final pacificTime = DateTime.parse('2026-06-22T09:00:00-07:00');
final sameInstantUtc = DateTime.utc(2026, 6, 22, 16);
final stored = PersistenceDateTimeConvention.toStoredString(pacificTime);
final restored = PersistenceDateTimeConvention.fromStoredString(stored);
expect(PersistenceDateTimeConvention.description, contains('UTC'));
expect(stored, '2026-06-22T16:00:00.000Z');
expect(restored, sameInstantUtc);
expect(
PersistenceDateTimeConvention.compare(pacificTime, sameInstantUtc),
0,
);
});
test('nullable task fields are preserved unless explicitly cleared', () {
final original = scheduledTask(
id: 'nullable-task',
createdAt: createdAt,
).copyWith(
priority: PriorityLevel.high,
parentTaskId: 'parent',
);
final preserved = original.copyWith(title: 'Updated');
final clearedSchedule = original.copyWith(clearSchedule: true);
expect(preserved.priority, PriorityLevel.high);
expect(preserved.durationMinutes, original.durationMinutes);
expect(preserved.scheduledStart, original.scheduledStart);
expect(preserved.scheduledEnd, original.scheduledEnd);
expect(preserved.parentTaskId, 'parent');
expect(clearedSchedule.scheduledStart, isNull);
expect(clearedSchedule.scheduledEnd, isNull);
expect(clearedSchedule.id, original.id);
});
test('enum persistence names stay stable and distinguish not set values',
() {
expect(TaskType.flexible.persistenceName, 'flexible');
expect(TaskStatus.backlog.persistenceName, 'backlog');
expect(PriorityLevel.veryHigh.persistenceName, 'veryHigh');
expect(RewardLevel.notSet.persistenceName, 'notSet');
expect(RewardLevel.veryLow.persistenceName, 'veryLow');
expect(DifficultyLevel.veryHard.persistenceName, 'veryHard');
expect(ReminderProfile.gentle.persistenceName, 'gentle');
expect(BacklogTag.wishlist.persistenceName, 'wishlist');
expect(LockedWeekday.monday.persistenceName, 'monday');
expect(LockedBlockOverrideType.replace.persistenceName, 'replace');
expect(
RewardLevel.notSet.persistenceName,
isNot(RewardLevel.veryLow.persistenceName),
);
});
test('backlog age behavior uses task creation timestamp', () {
final oldButRecentlyUpdated = Task.quickCapture(
id: 'old',
title: 'Old backlog idea',
createdAt: DateTime.utc(2026, 5, 1),
updatedAt: DateTime.utc(2026, 6, 21),
);
final view = BacklogView(
tasks: [oldButRecentlyUpdated],
now: DateTime.utc(2026, 6, 22),
);
expect(view.filter(BacklogFilter.stale), [oldButRecentlyUpdated]);
expect(
view.stalenessMarkerFor(oldButRecentlyUpdated),
BacklogStalenessMarker.purple,
);
});
test('in-memory repositories round-trip scheduling fields', () async {
final task = scheduledTask(
id: 'scheduled',
createdAt: createdAt,
);
final repository = InMemoryTaskRepository();
await repository.save(task);
final saved = await repository.findById(task.id);
expect(saved, isNotNull);
expect(saved!.id, task.id);
expect(saved.scheduledStart, task.scheduledStart);
expect(saved.scheduledEnd, task.scheduledEnd);
expect(saved.durationMinutes, task.durationMinutes);
expect(saved.projectId, task.projectId);
expect(saved.reward, task.reward);
expect(saved.difficulty, task.difficulty);
});
test('repository read operations do not expose mutable collections',
() async {
final original = scheduledTask(
id: 'immutable',
createdAt: createdAt,
);
final repository = InMemoryTaskRepository([original]);
final savedList = await repository.findAll();
expect(() => savedList.add(original), throwsUnsupportedError);
expect((await repository.findAll()).map((task) => task.id), [
'immutable',
]);
expect(original.scheduledStart, DateTime.utc(2026, 6, 22, 9));
});
test('document field sets preserve all task and statistics fields', () {
expect(TaskDocumentFields.all, {
'_id',
'title',
'projectId',
'type',
'status',
'priority',
'reward',
'difficulty',
'durationMinutes',
'scheduledStart',
'scheduledEnd',
'parentTaskId',
'backlogTags',
'createdAt',
'updatedAt',
'stats',
});
expect(TaskStatisticsDocumentFields.all, {
'skippedDuringBurnoutCount',
'manuallyPushedCount',
'autoPushedCount',
'movedToBacklogCount',
'restoredFromBacklogCount',
'missedCount',
'cancelledCount',
'completedLateCount',
'completedDuringLockedHoursCount',
'completedDuringLockedHoursMinutes',
});
});
test('document field sets cover project locked and snapshot documents', () {
expect(ProjectDocumentFields.all, containsAll(['_id', 'colorKey']));
expect(
LockedBlockDocumentFields.all,
containsAll(['_id', 'startTime', 'recurrence', 'hiddenByDefault']),
);
expect(
LockedBlockOverrideDocumentFields.all,
containsAll(['_id', 'lockedBlockId', 'type']),
);
expect(
SchedulingSnapshotDocumentFields.all,
containsAll([
'_id',
'capturedAt',
'window',
'tasks',
'lockedIntervals',
'requiredVisibleIntervals',
'notices',
'changes',
'overlaps',
'operationName',
]),
);
});
test('document field names are stable and migration-friendly', () {
for (final fieldSet in PersistenceDocumentFieldSets.all) {
expect(fieldSet, isNotEmpty);
for (final fieldName in fieldSet) {
expect(fieldName, isNotEmpty);
expect(fieldName.trim(), fieldName);
expect(fieldName.contains('.'), isFalse, reason: fieldName);
expect(fieldName.startsWith(r'$'), isFalse, reason: fieldName);
}
}
expect(DocumentFields.id, '_id');
expect(TaskDocumentFields.projectId, 'projectId');
expect(TaskDocumentFields.scheduledStart, 'scheduledStart');
expect(TaskDocumentFields.scheduledEnd, 'scheduledEnd');
});
});
}
Task scheduledTask({
required String id,
required DateTime createdAt,
}) {
return Task(
id: id,
title: 'Scheduled task',
projectId: 'project-1',
type: TaskType.flexible,
status: TaskStatus.planned,
priority: PriorityLevel.medium,
reward: RewardLevel.high,
difficulty: DifficultyLevel.medium,
durationMinutes: 30,
scheduledStart: DateTime.utc(2026, 6, 22, 9),
scheduledEnd: DateTime.utc(2026, 6, 22, 9, 30),
createdAt: createdAt,
updatedAt: createdAt,
);
}