forked from eva/focus-flow
363 lines
11 KiB
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,
|
|
);
|
|
}
|