forked from eva/focus-flow
355 lines
11 KiB
Dart
355 lines
11 KiB
Dart
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('Repository interfaces', () {
|
|
final createdAt = DateTime(2026, 6, 21, 8);
|
|
final window = SchedulingWindow(
|
|
start: DateTime(2026, 6, 21, 9),
|
|
end: DateTime(2026, 6, 21, 17),
|
|
);
|
|
|
|
test('task repository supports in-memory fakes for domain services',
|
|
() async {
|
|
final backlogTask = task(
|
|
id: 'backlog',
|
|
title: 'Backlog task',
|
|
status: TaskStatus.backlog,
|
|
createdAt: createdAt,
|
|
);
|
|
final scheduledTask = task(
|
|
id: 'scheduled',
|
|
title: 'Scheduled task',
|
|
status: TaskStatus.planned,
|
|
createdAt: createdAt,
|
|
scheduledStart: DateTime(2026, 6, 21, 10),
|
|
scheduledEnd: DateTime(2026, 6, 21, 10, 30),
|
|
);
|
|
final repository = InMemoryTaskRepository([backlogTask]);
|
|
|
|
await repository.save(scheduledTask);
|
|
|
|
expect(await repository.findById('backlog'), same(backlogTask));
|
|
expect(await repository.findByStatus(TaskStatus.backlog), [backlogTask]);
|
|
expect(
|
|
(await repository.findScheduledInWindow(window)).map((task) => task.id),
|
|
['scheduled'],
|
|
);
|
|
});
|
|
|
|
test('project repository stores project profiles by stable id', () async {
|
|
final project = ProjectProfile(
|
|
id: 'home',
|
|
name: 'Home',
|
|
colorKey: 'project-home',
|
|
);
|
|
final repository = InMemoryProjectRepository();
|
|
|
|
await repository.save(project);
|
|
|
|
expect(await repository.findById('home'), same(project));
|
|
expect(await repository.findAll(), [project]);
|
|
});
|
|
|
|
test('locked block repository separates blocks from overrides', () async {
|
|
final block = LockedBlock(
|
|
id: 'work',
|
|
name: 'Work',
|
|
startTime: ClockTime(hour: 9, minute: 0),
|
|
endTime: ClockTime(hour: 17, minute: 0),
|
|
recurrence: LockedBlockRecurrence.weekly(
|
|
weekdays: {LockedWeekday.monday},
|
|
),
|
|
createdAt: createdAt,
|
|
updatedAt: createdAt,
|
|
);
|
|
final override = LockedBlockOverride.remove(
|
|
id: 'holiday',
|
|
lockedBlockId: block.id,
|
|
date: CivilDate(2026, 6, 22),
|
|
createdAt: createdAt,
|
|
);
|
|
final repository = InMemoryLockedBlockRepository();
|
|
|
|
await repository.saveBlock(block);
|
|
await repository.saveOverride(override);
|
|
|
|
expect(await repository.findBlockById('work'), same(block));
|
|
expect(await repository.findOverrideById('holiday'), same(override));
|
|
expect(await repository.findAllBlocks(), [block]);
|
|
expect(await repository.findAllOverrides(), [override]);
|
|
});
|
|
|
|
test('scheduling snapshot repository preserves scheduler input shape',
|
|
() async {
|
|
final scheduledTask = task(
|
|
id: 'scheduled',
|
|
title: 'Scheduled task',
|
|
status: TaskStatus.planned,
|
|
createdAt: createdAt,
|
|
scheduledStart: DateTime(2026, 6, 21, 10),
|
|
scheduledEnd: DateTime(2026, 6, 21, 10, 30),
|
|
);
|
|
final lockedInterval = TimeInterval(
|
|
start: DateTime(2026, 6, 21, 12),
|
|
end: DateTime(2026, 6, 21, 13),
|
|
label: 'Lunch',
|
|
);
|
|
final snapshot = SchedulingStateSnapshot(
|
|
id: 'snapshot-1',
|
|
capturedAt: DateTime(2026, 6, 21, 8, 30),
|
|
window: window,
|
|
tasks: [scheduledTask],
|
|
lockedIntervals: [lockedInterval],
|
|
notices: [
|
|
SchedulingNotice('Saved schedule'),
|
|
],
|
|
operationName: 'load-day',
|
|
);
|
|
final repository = InMemorySchedulingSnapshotRepository();
|
|
|
|
await repository.save(snapshot);
|
|
|
|
final saved = await repository.findById('snapshot-1');
|
|
expect(saved, same(snapshot));
|
|
expect(saved!.input.tasks, [scheduledTask]);
|
|
expect(saved.input.lockedIntervals, [lockedInterval]);
|
|
expect(saved.result.tasks, [scheduledTask]);
|
|
expect(saved.result.notices.single.message, 'Saved schedule');
|
|
expect(
|
|
(await repository.findInWindow(window)).map((snapshot) => snapshot.id),
|
|
['snapshot-1'],
|
|
);
|
|
});
|
|
});
|
|
|
|
runCoreRepositoryConformance(
|
|
createTaskRepository: () => InMemoryTaskRepository(
|
|
[
|
|
task(
|
|
id: 'backlog-a',
|
|
title: 'Backlog A',
|
|
status: TaskStatus.backlog,
|
|
createdAt: DateTime.utc(2026, 6, 20),
|
|
),
|
|
task(
|
|
id: 'backlog-b',
|
|
title: 'Backlog B',
|
|
status: TaskStatus.backlog,
|
|
createdAt: DateTime.utc(2026, 6, 21),
|
|
).copyWith(projectId: 'home', parentTaskId: 'parent'),
|
|
task(
|
|
id: 'scheduled',
|
|
title: 'Scheduled',
|
|
status: TaskStatus.planned,
|
|
createdAt: DateTime.utc(2026, 6, 21),
|
|
scheduledStart: DateTime.utc(2026, 6, 22, 10),
|
|
scheduledEnd: DateTime.utc(2026, 6, 22, 10, 30),
|
|
).copyWith(projectId: 'home'),
|
|
],
|
|
),
|
|
createProjectRepository: () => InMemoryProjectRepository([
|
|
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'),
|
|
ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'),
|
|
]),
|
|
createLockedBlockRepository: () => InMemoryLockedBlockRepository(
|
|
initialBlocks: [
|
|
LockedBlock(
|
|
id: 'work',
|
|
name: 'Work',
|
|
startTime: ClockTime(hour: 9, minute: 0),
|
|
endTime: ClockTime(hour: 17, minute: 0),
|
|
recurrence: LockedBlockRecurrence.weekly(
|
|
weekdays: {LockedWeekday.monday},
|
|
),
|
|
createdAt: DateTime.utc(2026, 6, 20),
|
|
updatedAt: DateTime.utc(2026, 6, 20),
|
|
),
|
|
],
|
|
initialOverrides: [
|
|
LockedBlockOverride.remove(
|
|
id: 'holiday',
|
|
lockedBlockId: 'work',
|
|
date: CivilDate(2026, 6, 22),
|
|
createdAt: DateTime.utc(2026, 6, 20),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void runCoreRepositoryConformance({
|
|
required TaskRepository Function() createTaskRepository,
|
|
required ProjectRepository Function() createProjectRepository,
|
|
required LockedBlockRepository Function() createLockedBlockRepository,
|
|
}) {
|
|
group('Core repository conformance', () {
|
|
const ownerId = 'owner-1';
|
|
|
|
test('task queries are owner-scoped, ordered, paged, and revisioned',
|
|
() async {
|
|
final repository = createTaskRepository();
|
|
final firstPage = await repository.findByOwner(
|
|
ownerId: ownerId,
|
|
page: const RepositoryPageRequest(limit: 2),
|
|
);
|
|
final secondPage = await repository.findByOwner(
|
|
ownerId: ownerId,
|
|
page: RepositoryPageRequest(cursor: firstPage.nextCursor, limit: 2),
|
|
);
|
|
final homeTasks = await repository.findByProjectId(
|
|
ownerId: ownerId,
|
|
projectId: 'home',
|
|
);
|
|
final children = await repository.findByParentTaskId(
|
|
ownerId: ownerId,
|
|
parentTaskId: 'parent',
|
|
);
|
|
final backlog = await repository.findBacklogCandidates(
|
|
const BacklogCandidateQuery(ownerId: ownerId, projectId: 'home'),
|
|
);
|
|
final localDay = await repository.findForLocalDay(
|
|
ownerId: ownerId,
|
|
localDate: CivilDate(2026, 6, 22),
|
|
window: SchedulingWindow(
|
|
start: DateTime.utc(2026, 6, 22),
|
|
end: DateTime.utc(2026, 6, 23),
|
|
),
|
|
);
|
|
final record = await repository.findRecordById('scheduled');
|
|
final updated = record!.value.copyWith(
|
|
title: 'Updated scheduled',
|
|
updatedAt: DateTime.utc(2026, 6, 22, 12),
|
|
);
|
|
|
|
expect(firstPage.items.map((task) => task.id), [
|
|
'backlog-a',
|
|
'backlog-b',
|
|
]);
|
|
expect(firstPage.hasMore, isTrue);
|
|
expect(secondPage.items.map((task) => task.id), ['scheduled']);
|
|
expect(homeTasks.items.map((task) => task.id), [
|
|
'backlog-b',
|
|
'scheduled',
|
|
]);
|
|
expect(children.items.map((task) => task.id), ['backlog-b']);
|
|
expect(backlog.items.map((task) => task.id), ['backlog-b']);
|
|
expect(localDay.items.map((task) => task.id), ['scheduled']);
|
|
expect(record.ownerId, ownerId);
|
|
expect(record.revision, 1);
|
|
|
|
final stale = await repository.saveIfRevision(
|
|
task: updated,
|
|
ownerId: ownerId,
|
|
expectedRevision: 99,
|
|
);
|
|
expect(stale.failure!.code, RepositoryFailureCode.staleRevision);
|
|
expect(stale.failure!.actualRevision, 1);
|
|
|
|
final saved = await repository.saveIfRevision(
|
|
task: updated,
|
|
ownerId: ownerId,
|
|
expectedRevision: 1,
|
|
);
|
|
expect(saved.isSuccess, isTrue);
|
|
expect(saved.revision, 2);
|
|
expect((await repository.findRecordById('scheduled'))!.revision, 2);
|
|
|
|
expect(
|
|
() => firstPage.items.add(record.value),
|
|
throwsUnsupportedError,
|
|
);
|
|
});
|
|
|
|
test('project queries hide archived records by default and honor revisions',
|
|
() async {
|
|
final repository = createProjectRepository();
|
|
final record = await repository.findRecordById('home');
|
|
|
|
final archived = await repository.archive(
|
|
projectId: 'home',
|
|
ownerId: ownerId,
|
|
expectedRevision: record!.revision,
|
|
archivedAt: DateTime.utc(2026, 6, 25),
|
|
);
|
|
final visible = await repository.findByOwner(ownerId: ownerId);
|
|
final includingArchived = await repository.findByOwner(
|
|
ownerId: ownerId,
|
|
includeArchived: true,
|
|
);
|
|
|
|
expect(archived.isSuccess, isTrue);
|
|
expect(visible.items.map((project) => project.id), ['work']);
|
|
expect(includingArchived.items.map((project) => project.id), [
|
|
'home',
|
|
'work',
|
|
]);
|
|
expect(
|
|
(await repository.findRecordById('home'))!.archivedAt,
|
|
DateTime.utc(2026, 6, 25),
|
|
);
|
|
expect(
|
|
(await repository.insert(
|
|
project: ProjectProfile(id: 'home', name: 'Home', colorKey: 'again'),
|
|
ownerId: ownerId,
|
|
))
|
|
.failure!
|
|
.code,
|
|
RepositoryFailureCode.duplicateId,
|
|
);
|
|
});
|
|
|
|
test('locked queries find date-scoped overrides and archive by revision',
|
|
() async {
|
|
final repository = createLockedBlockRepository();
|
|
final blockRecord = await repository.findBlockRecordById('work');
|
|
final overrides = await repository.findOverridesForDate(
|
|
ownerId: ownerId,
|
|
date: CivilDate(2026, 6, 22),
|
|
);
|
|
|
|
final archived = await repository.archiveBlock(
|
|
blockId: 'work',
|
|
ownerId: ownerId,
|
|
expectedRevision: blockRecord!.revision,
|
|
archivedAt: DateTime.utc(2026, 6, 25),
|
|
);
|
|
final visible = await repository.findBlocksByOwner(ownerId: ownerId);
|
|
final includingArchived = await repository.findBlocksByOwner(
|
|
ownerId: ownerId,
|
|
includeArchived: true,
|
|
);
|
|
|
|
expect(overrides.items.map((override) => override.id), ['holiday']);
|
|
expect(archived.isSuccess, isTrue);
|
|
expect(visible.items, isEmpty);
|
|
expect(includingArchived.items.map((block) => block.id), ['work']);
|
|
expect(
|
|
(await repository.findBlockRecordById('work'))!.archivedAt,
|
|
DateTime.utc(2026, 6, 25),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
Task task({
|
|
required String id,
|
|
required String title,
|
|
required TaskStatus status,
|
|
required DateTime createdAt,
|
|
DateTime? scheduledStart,
|
|
DateTime? scheduledEnd,
|
|
}) {
|
|
return Task(
|
|
id: id,
|
|
title: title,
|
|
projectId: 'inbox',
|
|
type: TaskType.flexible,
|
|
status: status,
|
|
durationMinutes: 30,
|
|
scheduledStart: scheduledStart,
|
|
scheduledEnd: scheduledEnd,
|
|
createdAt: createdAt,
|
|
updatedAt: createdAt,
|
|
);
|
|
}
|