focus-flow/test/timeline_state_test.dart

511 lines
16 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);
final project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'project-home',
);
final mapper = TimelineItemMapper.fromProjects([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('background token maps from every task type', () {
final expectedTokens = {
TaskType.flexible: TimelineBackgroundToken.flexible,
TaskType.inflexible: TimelineBackgroundToken.inflexible,
TaskType.critical: TimelineBackgroundToken.critical,
TaskType.locked: TimelineBackgroundToken.locked,
TaskType.surprise: TimelineBackgroundToken.surprise,
TaskType.freeSlot: TimelineBackgroundToken.freeSlot,
};
for (final entry in expectedTokens.entries) {
final task = scheduledTask(
id: entry.key.name,
title: entry.key.name,
projectId: project.id,
type: entry.key,
createdAt: createdAt,
start: start,
end: end,
);
expect(
mapper.fromTask(task).backgroundToken,
entry.value,
reason: entry.key.name,
);
}
});
test('reward icon token handles every reward level plus not set', () {
final expectedTokens = {
RewardLevel.notSet: TimelineRewardIconToken.notSet,
RewardLevel.veryLow: TimelineRewardIconToken.veryLow,
RewardLevel.low: TimelineRewardIconToken.low,
RewardLevel.medium: TimelineRewardIconToken.medium,
RewardLevel.high: TimelineRewardIconToken.high,
RewardLevel.veryHigh: TimelineRewardIconToken.veryHigh,
};
for (final entry in expectedTokens.entries) {
final task = scheduledTask(
id: entry.key.name,
title: entry.key.name,
projectId: project.id,
type: TaskType.flexible,
reward: entry.key,
createdAt: createdAt,
start: start,
end: end,
);
expect(
mapper.fromTask(task).rewardIconToken,
entry.value,
reason: entry.key.name,
);
}
});
test('difficulty icon token handles every difficulty level plus not set',
() {
final expectedTokens = {
DifficultyLevel.notSet: TimelineDifficultyIconToken.notSet,
DifficultyLevel.veryEasy: TimelineDifficultyIconToken.veryEasy,
DifficultyLevel.easy: TimelineDifficultyIconToken.easy,
DifficultyLevel.medium: TimelineDifficultyIconToken.medium,
DifficultyLevel.hard: TimelineDifficultyIconToken.hard,
DifficultyLevel.veryHard: TimelineDifficultyIconToken.veryHard,
};
for (final entry in expectedTokens.entries) {
final task = scheduledTask(
id: entry.key.name,
title: entry.key.name,
projectId: project.id,
type: TaskType.flexible,
difficulty: entry.key,
createdAt: createdAt,
start: start,
end: end,
);
expect(
mapper.fromTask(task).difficultyIconToken,
entry.value,
reason: entry.key.name,
);
}
});
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']);
});
test('compact mode returns only the reduced current and next items', () {
final current = scheduledTask(
id: 'current',
title: 'Current',
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 required',
projectId: project.id,
type: TaskType.inflexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 10),
end: DateTime(2026, 6, 20, 10, 30),
);
final laterRequired = scheduledTask(
id: 'later-required',
title: 'Later required',
projectId: project.id,
type: TaskType.critical,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 12),
end: DateTime(2026, 6, 20, 12, 30),
);
final laterFlexible = scheduledTask(
id: 'later-flexible',
title: 'Later flexible',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 13),
end: DateTime(2026, 6, 20, 13, 30),
);
final state = mapper.compactStateForTasks(
tasks: [laterFlexible, laterRequired, nextRequired, current],
now: DateTime(2026, 6, 20, 9, 15),
manualCompactModeEnabled: true,
);
expect(state.compactItems.map((item) => item.id), [
'current',
'next-required',
]);
expect(
state.compactItems.map((item) => item.id),
isNot(contains('later-required')),
);
expect(
state.compactItems.map((item) => item.id),
isNot(contains('later-flexible')),
);
});
});
}
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,
);
}