test(data): add MongoDB persistence edge-case regressions
This commit is contained in:
parent
305090250a
commit
9537e06c3c
7 changed files with 684 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
|
||||
|
|
@ -84,6 +84,21 @@ Acceptance criteria:
|
|||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Chunk 9.3 — MongoDB-document-safe models
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
|
||||
|
|
@ -84,6 +84,21 @@ Acceptance criteria:
|
|||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Chunk 9.3 — MongoDB-document-safe models
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export 'src/models.dart';
|
|||
export 'src/backlog.dart';
|
||||
export 'src/child_tasks.dart';
|
||||
export 'src/locked_time.dart';
|
||||
export 'src/persistence_contract.dart';
|
||||
export 'src/quick_capture.dart';
|
||||
export 'src/repositories.dart';
|
||||
export 'src/scheduling_engine.dart';
|
||||
|
|
|
|||
|
|
@ -136,6 +136,33 @@ class LockedBlock {
|
|||
|
||||
/// Convenience check for whether this block expands through recurrence.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -464,6 +464,31 @@ class ProjectProfile {
|
|||
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.
|
||||
|
|
|
|||
319
lib/src/persistence_contract.dart
Normal file
319
lib/src/persistence_contract.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
280
test/persistence_edge_cases_test.dart
Normal file
280
test/persistence_edge_cases_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue