focus-flow/test/repositories_test.dart

370 lines
12 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,
);
final invalidRevision = await repository.saveIfRevision(
task: updated,
ownerId: ownerId,
expectedRevision: 0,
);
final wrongOwner = await repository.saveIfRevision(
task: updated,
ownerId: 'other-owner',
expectedRevision: 1,
);
expect(stale.failure!.code, RepositoryFailureCode.staleRevision);
expect(stale.failure!.actualRevision, 1);
expect(
invalidRevision.failure!.code,
RepositoryFailureCode.invalidRevision,
);
expect(wrongOwner.failure!.code, RepositoryFailureCode.ownerMismatch);
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,
);
}