forked from eva/focus-flow
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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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