focus-flow/test/timeline_state_test.dart

363 lines
11 KiB
Dart

import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Timeline item view model', () {
final createdAt = DateTime(2026, 6, 20, 8);
final start = DateTime(2026, 6, 20, 9);
final end = DateTime(2026, 6, 20, 9, 30);
const project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'project-home',
);
final mapper = TimelineItemMapper.fromProjects(const [project]);
test('maps task fields to display tokens', () {
final task = scheduledTask(
id: 'dishes',
title: 'Wash dishes',
projectId: project.id,
type: TaskType.flexible,
reward: RewardLevel.high,
difficulty: DifficultyLevel.hard,
createdAt: createdAt,
start: start,
end: end,
);
final item = mapper.fromTask(task);
expect(item.id, 'dishes');
expect(item.displayTitle, 'Wash dishes');
expect(item.taskType, TaskType.flexible);
expect(item.projectColorToken, 'project-home');
expect(item.backgroundToken, TimelineBackgroundToken.flexible);
expect(item.rewardIconToken, TimelineRewardIconToken.high);
expect(item.difficultyIconToken, TimelineDifficultyIconToken.hard);
expect(item.category, TimelineItemCategory.taskCard);
expect(item.quickActions, [
TimelineQuickAction.done,
TimelineQuickAction.push,
TimelineQuickAction.backlog,
TimelineQuickAction.breakUp,
]);
});
test('flexible task cards display duration while retaining position', () {
final task = scheduledTask(
id: 'laundry',
title: 'Fold laundry',
projectId: project.id,
type: TaskType.flexible,
durationMinutes: 25,
createdAt: createdAt,
start: start,
end: start.add(const Duration(minutes: 25)),
);
final item = mapper.fromTask(task);
expect(item.showsExplicitTime, isFalse);
expect(item.durationMinutes, 25);
expect(item.start, start);
expect(item.end, start.add(const Duration(minutes: 25)));
});
test('flexible duration can fall back to scheduled interval', () {
final task = scheduledTask(
id: 'admin',
title: 'Handle admin',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: start,
end: start.add(const Duration(minutes: 45)),
);
final item = mapper.fromTask(task);
expect(item.showsExplicitTime, isFalse);
expect(item.durationMinutes, 45);
});
test('inflexible critical and locked items show explicit times', () {
for (final type in [
TaskType.inflexible,
TaskType.critical,
TaskType.locked,
]) {
final task = scheduledTask(
id: type.name,
title: type.name,
projectId: project.id,
type: type,
createdAt: createdAt,
start: start,
end: end,
);
final item = mapper.fromTask(task);
expect(item.showsExplicitTime, isTrue, reason: type.name);
expect(item.start, start, reason: type.name);
expect(item.end, end, reason: type.name);
expect(item.durationMinutes, isNull, reason: type.name);
}
});
test('locked item is an overlay without normal task quick actions', () {
final task = scheduledTask(
id: 'sleep',
title: 'Sleep',
projectId: 'locked',
type: TaskType.locked,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 0),
end: DateTime(2026, 6, 20, 7),
);
final item = mapper.fromTask(task);
expect(item.category, TimelineItemCategory.overlay);
expect(item.projectColorToken, 'locked');
expect(item.backgroundToken, TimelineBackgroundToken.locked);
expect(item.quickActions, isEmpty);
});
test('hidden locked occurrences stay hidden until reveal mode is active',
() {
final occurrence = LockedBlockOccurrence(
name: 'Work',
interval: TimeInterval(
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 17),
),
hiddenByDefault: true,
lockedBlockId: 'work-hours',
projectId: project.id,
);
final hiddenItem = mapper.fromLockedOccurrence(
occurrence,
revealHiddenLockedBlocks: false,
);
final revealedItem = mapper.fromLockedOccurrence(
occurrence,
revealHiddenLockedBlocks: true,
);
expect(hiddenItem, isNull);
expect(revealedItem, isNotNull);
expect(revealedItem!.id, 'locked-block:work-hours');
expect(revealedItem.displayTitle, 'Work');
expect(revealedItem.start, DateTime(2026, 6, 20, 9));
expect(revealedItem.end, DateTime(2026, 6, 20, 17));
expect(revealedItem.showsExplicitTime, isTrue);
expect(revealedItem.category, TimelineItemCategory.overlay);
expect(revealedItem.projectColorToken, 'project-home');
expect(revealedItem.quickActions, isEmpty);
});
test('visible locked occurrences can produce overlays without reveal mode',
() {
final occurrence = LockedBlockOccurrence(
name: 'Appointment',
interval: TimeInterval(
start: DateTime(2026, 6, 20, 11),
end: DateTime(2026, 6, 20, 12),
),
hiddenByDefault: false,
overrideId: 'appointment-override',
);
final item = mapper.fromLockedOccurrence(
occurrence,
revealHiddenLockedBlocks: false,
);
expect(item, isNotNull);
expect(item!.id, 'locked-override:appointment-override');
expect(item.displayTitle, 'Appointment');
expect(item.category, TimelineItemCategory.overlay);
expect(item.taskType, TaskType.locked);
expect(item.backgroundToken, TimelineBackgroundToken.locked);
expect(item.rewardIconToken, TimelineRewardIconToken.notSet);
expect(item.difficultyIconToken, TimelineDifficultyIconToken.notSet);
expect(item.showsExplicitTime, isTrue);
expect(item.quickActions, isEmpty);
});
test('manual compact mode flag controls compact output selection', () {
final current = scheduledTask(
id: 'current',
title: 'Current task',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10),
);
final nextRequired = scheduledTask(
id: 'next-required',
title: 'Next appointment',
projectId: project.id,
type: TaskType.inflexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 11),
end: DateTime(2026, 6, 20, 11, 30),
);
final disabled = mapper.compactStateForTasks(
tasks: [current, nextRequired],
now: DateTime(2026, 6, 20, 9, 15),
manualCompactModeEnabled: false,
);
final enabled = mapper.compactStateForTasks(
tasks: [current, nextRequired],
now: DateTime(2026, 6, 20, 9, 15),
manualCompactModeEnabled: true,
);
expect(disabled.manualCompactModeEnabled, isFalse);
expect(disabled.compactItems, isEmpty);
expect(disabled.fullTimelineVisible, isTrue);
expect(enabled.manualCompactModeEnabled, isTrue);
expect(enabled.currentItem!.id, 'current');
expect(enabled.nextRequiredItem!.id, 'next-required');
expect(enabled.compactItems.map((item) => item.id), [
'current',
'next-required',
]);
expect(enabled.fullTimelineVisible, isFalse);
});
test('compact mode can include the next flexible task when requested', () {
final required = scheduledTask(
id: 'required',
title: 'Required',
projectId: project.id,
type: TaskType.critical,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 10),
end: DateTime(2026, 6, 20, 10, 30),
);
final flexible = scheduledTask(
id: 'flexible',
title: 'Flexible',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 11),
end: DateTime(2026, 6, 20, 11, 30),
);
final state = mapper.compactStateForTasks(
tasks: [flexible, required],
now: DateTime(2026, 6, 20, 9),
manualCompactModeEnabled: true,
includeNextFlexibleTask: true,
);
expect(state.currentItem, isNull);
expect(state.nextRequiredItem!.id, 'required');
expect(state.nextFlexibleItem!.id, 'flexible');
expect(state.compactItems.map((item) => item.id), [
'required',
'flexible',
]);
});
test('compact mode does not activate after pushed or missed tasks', () {
final pushed = scheduledTask(
id: 'pushed',
title: 'Pushed task',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 14),
end: DateTime(2026, 6, 20, 14, 30),
);
final missed = scheduledTask(
id: 'missed',
title: 'Missed appointment',
projectId: project.id,
type: TaskType.inflexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 8),
end: DateTime(2026, 6, 20, 8, 30),
).copyWith(status: TaskStatus.missed);
final state = mapper.compactStateForTasks(
tasks: [pushed, missed],
now: DateTime(2026, 6, 20, 9),
manualCompactModeEnabled: false,
);
expect(state.manualCompactModeEnabled, isFalse);
expect(state.compactItems, isEmpty);
expect(state.fullTimelineVisible, isTrue);
});
test('compact mode ignores locked items unless overlay reveal is separate',
() {
final locked = scheduledTask(
id: 'locked',
title: 'Hidden blocked time',
projectId: 'locked',
type: TaskType.locked,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10),
);
final required = scheduledTask(
id: 'required',
title: 'Visible required task',
projectId: project.id,
type: TaskType.critical,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 10),
end: DateTime(2026, 6, 20, 10, 30),
);
final state = mapper.compactStateForTasks(
tasks: [locked, required],
now: DateTime(2026, 6, 20, 9, 15),
manualCompactModeEnabled: true,
);
expect(state.currentItem, isNull);
expect(state.nextRequiredItem!.id, 'required');
expect(state.compactItems.map((item) => item.id), ['required']);
});
});
}
Task scheduledTask({
required String id,
required String title,
required String projectId,
required TaskType type,
required DateTime createdAt,
required DateTime start,
required DateTime end,
RewardLevel reward = RewardLevel.notSet,
DifficultyLevel difficulty = DifficultyLevel.notSet,
int? durationMinutes,
}) {
return Task(
id: id,
title: title,
projectId: projectId,
type: type,
status: TaskStatus.planned,
reward: reward,
difficulty: difficulty,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: end,
createdAt: createdAt,
updatedAt: createdAt,
);
}