531 lines
17 KiB
Dart
531 lines
17 KiB
Dart
/// Tests Widget behavior in the FocusFlow Flutter app.
|
|
library;
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:scheduler_core/scheduler_core.dart';
|
|
|
|
import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
|
|
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
|
|
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
|
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
|
import 'package:focus_flow_flutter/widgets/required_banner.dart';
|
|
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
|
|
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
|
|
import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart';
|
|
import 'package:focus_flow_flutter/widgets/top_bar.dart';
|
|
|
|
/// Runs FocusFlow widget and visual adapter tests.
|
|
void main() {
|
|
testWidgets('app shell renders compact Today frame', (tester) async {
|
|
await _pumpPlanApp(tester);
|
|
|
|
expect(find.text('FocusFlow'), findsOneWidget);
|
|
expect(find.text('Today'), findsWidgets);
|
|
expect(find.text('Backlog'), findsOneWidget);
|
|
expect(find.text('Projects'), findsOneWidget);
|
|
expect(find.text('Reports'), findsOneWidget);
|
|
expect(find.text('Settings'), findsWidgets);
|
|
expect(find.text('Compact'), findsOneWidget);
|
|
expect(find.text('Normal'), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('seeded Today renders backend-backed compact mockup data', (
|
|
tester,
|
|
) async {
|
|
await _pumpPlanApp(tester);
|
|
|
|
expect(find.text('May 20, 2025'), findsOneWidget);
|
|
expect(
|
|
find.textContaining('Next required task:', findRichText: true),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Clean coffee maker'), findsOneWidget);
|
|
expect(find.text('Cleaned kitchen counter'), findsNWidgets(2));
|
|
expect(find.text('Sort mail'), findsOneWidget);
|
|
expect(find.text('Start laundry'), findsOneWidget);
|
|
expect(find.text('Water plants'), findsOneWidget);
|
|
expect(find.text('Pay bill'), findsOneWidget);
|
|
expect(find.text('Doctor appointment'), findsOneWidget);
|
|
expect(find.text('Free Slot'), findsOneWidget);
|
|
expect(find.text('Intentional rest'), findsOneWidget);
|
|
});
|
|
|
|
test(
|
|
'visual token adapter maps seeded items to expected visual kinds',
|
|
() async {
|
|
final state = await _readSeededToday();
|
|
final data = TodayScreenData.fromTodayState(state);
|
|
|
|
expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible);
|
|
expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible);
|
|
expect(_kindFor(data, 'start-laundry'), TaskVisualKind.flexible);
|
|
expect(_kindFor(data, 'water-plants'), TaskVisualKind.flexible);
|
|
expect(_kindFor(data, 'pay-bill'), TaskVisualKind.required);
|
|
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
|
|
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
|
|
expect(
|
|
_kindFor(data, 'cleaned-kitchen-counter-early'),
|
|
TaskVisualKind.completedSurprise,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('difficulty bars map every difficulty level to five-bar fill count', () {
|
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.notSet), 0);
|
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryEasy), 1);
|
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.easy), 2);
|
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.medium), 3);
|
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.hard), 4);
|
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
|
});
|
|
|
|
testWidgets('task modal opens closes and keeps action buttons inert', (
|
|
tester,
|
|
) async {
|
|
await _pumpPlanApp(tester);
|
|
|
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
|
|
|
await _scrollIntoView(tester, const ValueKey('pay-bill'));
|
|
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
|
expect(find.textContaining('Required'), findsWidgets);
|
|
expect(find.text('Reward level 2'), findsOneWidget);
|
|
expect(find.text('Medium effort'), findsOneWidget);
|
|
expect(find.text('Done'), findsOneWidget);
|
|
expect(find.text('Push'), findsOneWidget);
|
|
expect(find.text('Move to Backlog'), findsOneWidget);
|
|
expect(find.text('Break up'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Done'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
|
|
|
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
|
|
|
await _scrollIntoView(tester, const ValueKey('pay-bill'));
|
|
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tapAt(const Offset(1450, 140));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
|
expect(find.text('Pay bill'), findsOneWidget);
|
|
});
|
|
|
|
test('Today screen timeline range covers the full day', () {
|
|
expect(TodayScreenData.defaultRange.startMinutes, 0);
|
|
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
|
});
|
|
|
|
testWidgets('timeline task bubbles match duration height exactly', (
|
|
tester,
|
|
) async {
|
|
await _pumpConstrainedWidget(
|
|
tester,
|
|
size: const Size(680, 360),
|
|
child: TimelineView(
|
|
cards: [
|
|
_timelineCard(
|
|
id: 'five-minute-task',
|
|
title: 'Five minute task',
|
|
subtitle: '5 min',
|
|
startMinutes: 8 * 60,
|
|
endMinutes: 8 * 60 + 5,
|
|
),
|
|
_timelineCard(
|
|
id: 'fifteen-minute-task',
|
|
title: 'Fifteen minute task',
|
|
subtitle: '15 min',
|
|
startMinutes: 8 * 60 + 5,
|
|
endMinutes: 8 * 60 + 20,
|
|
),
|
|
],
|
|
range: TodayScreenData.defaultRange,
|
|
now: () => DateTime(2026, 6, 30, 8),
|
|
onCardSelected: (_) {},
|
|
),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
final fiveMinute = tester.getRect(
|
|
find.byKey(const ValueKey('five-minute-task')),
|
|
);
|
|
final fifteenMinute = tester.getRect(
|
|
find.byKey(const ValueKey('fifteen-minute-task')),
|
|
);
|
|
|
|
expect(fiveMinute.height, closeTo(5 * 2.45, 0.5));
|
|
expect(fifteenMinute.height, closeTo(15 * 2.45, 0.5));
|
|
expect(fiveMinute.left, closeTo(fifteenMinute.left, 1));
|
|
expect(fiveMinute.bottom, closeTo(fifteenMinute.top, 1));
|
|
});
|
|
|
|
testWidgets('required banner jumps timeline to linked task', (tester) async {
|
|
await _pumpPlanApp(tester);
|
|
|
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
|
await tester.tap(find.text('Show upcoming'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(scrollable.position.pixels, closeTo(2622, 1));
|
|
expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'timeline defaults near the current time while keeping full day',
|
|
(tester) async {
|
|
await _pumpConstrainedWidget(
|
|
tester,
|
|
size: const Size(680, 400),
|
|
child: TimelineView(
|
|
cards: const [],
|
|
range: TodayScreenData.defaultRange,
|
|
now: () => DateTime(2026, 6, 30, 12),
|
|
onCardSelected: (_) {},
|
|
),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.text('11:00 PM'), findsOneWidget);
|
|
expect(find.text('12:00 AM'), findsWidgets);
|
|
|
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
|
final position = scrollable.position;
|
|
expect(position.maxScrollExtent, greaterThan(3000));
|
|
expect(position.pixels, closeTo(1564, 1));
|
|
},
|
|
);
|
|
|
|
testWidgets('timeline splits partial overlaps and reuses empty lanes', (
|
|
tester,
|
|
) async {
|
|
await _pumpConstrainedWidget(
|
|
tester,
|
|
size: const Size(680, 520),
|
|
child: TimelineView(
|
|
cards: [
|
|
_timelineCard(
|
|
id: 'left-first',
|
|
title: 'Left first',
|
|
startMinutes: 9 * 60,
|
|
endMinutes: 10 * 60,
|
|
),
|
|
_timelineCard(
|
|
id: 'right-middle',
|
|
title: 'Right middle',
|
|
startMinutes: 9 * 60 + 30,
|
|
endMinutes: 10 * 60 + 30,
|
|
),
|
|
_timelineCard(
|
|
id: 'left-reused',
|
|
title: 'Left reused',
|
|
startMinutes: 10 * 60,
|
|
endMinutes: 11 * 60,
|
|
),
|
|
],
|
|
range: TodayScreenData.defaultRange,
|
|
now: () => DateTime(2026, 6, 30, 9, 30),
|
|
onCardSelected: (_) {},
|
|
),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
final leftFirst = tester.getRect(find.byKey(const ValueKey('left-first')));
|
|
final rightMiddle = tester.getRect(
|
|
find.byKey(const ValueKey('right-middle')),
|
|
);
|
|
final leftReused = tester.getRect(
|
|
find.byKey(const ValueKey('left-reused')),
|
|
);
|
|
|
|
expect(rightMiddle.left, greaterThan(leftFirst.left));
|
|
expect(leftReused.left, closeTo(leftFirst.left, 1));
|
|
expect(leftFirst.width, closeTo(rightMiddle.width, 1));
|
|
expect(leftReused.width, closeTo(leftFirst.width, 1));
|
|
expect(leftFirst.right, lessThan(rightMiddle.left));
|
|
expect(leftReused.right, lessThan(rightMiddle.left));
|
|
});
|
|
|
|
testWidgets('seeded demo partial overlaps pack into two lanes', (
|
|
tester,
|
|
) async {
|
|
final state = await _readSeededToday();
|
|
final data = TodayScreenData.fromTodayState(state);
|
|
|
|
await _pumpConstrainedWidget(
|
|
tester,
|
|
size: const Size(900, 620),
|
|
child: TimelineView(
|
|
cards: data.cards,
|
|
range: data.timelineRange,
|
|
now: () => DateTime(2025, 5, 20, 15, 20),
|
|
onCardSelected: (_) {},
|
|
),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
final sortMail = tester.getRect(find.byKey(const ValueKey('sort-mail')));
|
|
final startLaundry = tester.getRect(
|
|
find.byKey(const ValueKey('start-laundry')),
|
|
);
|
|
final waterPlants = tester.getRect(
|
|
find.byKey(const ValueKey('water-plants')),
|
|
);
|
|
final cleanCoffee = tester.getRect(
|
|
find.byKey(const ValueKey('clean-coffee-maker')),
|
|
);
|
|
final cleanedKitchenEarly = tester.getRect(
|
|
find.byKey(const ValueKey('cleaned-kitchen-counter-early')),
|
|
);
|
|
|
|
expect(startLaundry.left, greaterThan(sortMail.left));
|
|
expect(waterPlants.left, closeTo(sortMail.left, 1));
|
|
expect(sortMail.width, closeTo(startLaundry.width, 1));
|
|
expect(waterPlants.width, closeTo(sortMail.width, 1));
|
|
expect(sortMail.right, lessThan(startLaundry.left));
|
|
expect(waterPlants.right, lessThan(startLaundry.left));
|
|
expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1));
|
|
expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5));
|
|
expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5));
|
|
expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top));
|
|
});
|
|
|
|
testWidgets('top bar controls scale inside a compact header width', (
|
|
tester,
|
|
) async {
|
|
await _pumpConstrainedWidget(
|
|
tester,
|
|
size: const Size(520, 72),
|
|
child: const TopBar(dateLabel: 'June 30, 2026'),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.text('Compact'), findsOneWidget);
|
|
expect(find.text('Normal'), findsOneWidget);
|
|
expect(find.text('Settings'), findsOneWidget);
|
|
expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(136));
|
|
});
|
|
|
|
testWidgets('required banner scales inside a compact header width', (
|
|
tester,
|
|
) async {
|
|
await _pumpConstrainedWidget(
|
|
tester,
|
|
size: const Size(440, 72),
|
|
child: const RequiredBanner(
|
|
model: RequiredBannerModel(
|
|
timelineCardId: 'pay-bill',
|
|
title: 'Pay bill',
|
|
timeText: '6:00 PM',
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(
|
|
find.textContaining('Next required task:', findRichText: true),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Show upcoming'), findsOneWidget);
|
|
expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176));
|
|
});
|
|
|
|
testWidgets('task card controls scale inside a narrow task bubble', (
|
|
tester,
|
|
) async {
|
|
await _pumpTimelineCard(
|
|
tester,
|
|
card: _timelineCard(
|
|
id: 'narrow-actions',
|
|
title: 'A task title that would otherwise crowd the controls',
|
|
),
|
|
size: const Size(320, 88),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(
|
|
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
|
0,
|
|
);
|
|
|
|
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
|
await gesture.addPointer(
|
|
location: tester.getCenter(find.byKey(const ValueKey('narrow-actions'))),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
|
1,
|
|
);
|
|
|
|
final button = tester.widget<IconButton>(
|
|
find.widgetWithIcon(IconButton, Icons.arrow_forward),
|
|
);
|
|
expect(button.iconSize, lessThan(18));
|
|
expect(button.constraints?.maxWidth, lessThan(32));
|
|
|
|
await gesture.removePointer();
|
|
});
|
|
|
|
testWidgets('short task card keeps time inline with title', (tester) async {
|
|
await _pumpTimelineCard(
|
|
tester,
|
|
card: _timelineCard(
|
|
id: 'short-inline-time',
|
|
title: 'Five minute reset',
|
|
subtitle: '5 min',
|
|
timeText: '6:00 PM - 6:05 PM',
|
|
startMinutes: 18 * 60,
|
|
endMinutes: 18 * 60 + 5,
|
|
),
|
|
size: const Size(360, 24),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
|
|
final title = tester.getRect(find.text('Five minute reset'));
|
|
final time = tester.getRect(find.text('5 min'));
|
|
|
|
expect(title.right, lessThan(time.left));
|
|
expect((title.center.dy - time.center.dy).abs(), lessThan(2));
|
|
});
|
|
|
|
testWidgets('free slot text scales inside a short task bubble', (
|
|
tester,
|
|
) async {
|
|
await _pumpTimelineCard(
|
|
tester,
|
|
card: _timelineCard(
|
|
id: 'short-free-slot',
|
|
title: 'Free Slot',
|
|
subtitle: 'Intentional rest',
|
|
timeText: '6:15 PM - 6:30 PM',
|
|
visualKind: TaskVisualKind.freeSlot,
|
|
showsQuickActions: false,
|
|
),
|
|
size: const Size(360, 88),
|
|
);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.text('Intentional rest'), findsOneWidget);
|
|
expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget);
|
|
});
|
|
}
|
|
|
|
Future<void> _pumpPlanApp(WidgetTester tester) async {
|
|
await tester.binding.setSurfaceSize(const Size(1586, 992));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
DemoSchedulerComposition _composition() {
|
|
return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20));
|
|
}
|
|
|
|
Future<TodayState> _readSeededToday() async {
|
|
final composition = _composition();
|
|
final result = await composition.todayQuery.execute(
|
|
GetTodayStateRequest(
|
|
context: composition.context('test-read-today'),
|
|
date: composition.date,
|
|
),
|
|
);
|
|
return result.requireValue;
|
|
}
|
|
|
|
TaskVisualKind _kindFor(TodayScreenData data, String id) {
|
|
return data.cards.singleWhere((card) => card.id == id).visualKind;
|
|
}
|
|
|
|
Future<void> _scrollIntoView(WidgetTester tester, ValueKey<String> key) async {
|
|
await tester.ensureVisible(find.byKey(key));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Future<void> _pumpTimelineCard(
|
|
WidgetTester tester, {
|
|
required TimelineCardModel card,
|
|
required Size size,
|
|
}) async {
|
|
await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: Center(
|
|
child: SizedBox(
|
|
width: size.width,
|
|
height: size.height,
|
|
child: TaskTimelineCard(card: card, onTap: () {}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Future<void> _pumpConstrainedWidget(
|
|
WidgetTester tester, {
|
|
required Size size,
|
|
required Widget child,
|
|
}) async {
|
|
await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: FocusFlowTheme.dark(),
|
|
home: Scaffold(
|
|
body: Center(
|
|
child: SizedBox(width: size.width, height: size.height, child: child),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
TimelineCardModel _timelineCard({
|
|
required String id,
|
|
required String title,
|
|
String subtitle = '30 min',
|
|
String timeText = '6:00 PM - 6:30 PM',
|
|
int startMinutes = 18 * 60,
|
|
int endMinutes = 18 * 60 + 30,
|
|
TaskVisualKind visualKind = TaskVisualKind.flexible,
|
|
bool showsQuickActions = true,
|
|
}) {
|
|
return TimelineCardModel(
|
|
id: id,
|
|
title: title,
|
|
typeLabel: 'Flexible',
|
|
subtitle: subtitle,
|
|
timeText: timeText,
|
|
startMinutes: startMinutes,
|
|
endMinutes: endMinutes,
|
|
visualKind: visualKind,
|
|
rewardIconToken: TimelineRewardIconToken.medium,
|
|
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
|
isCompleted: false,
|
|
isSelectable: true,
|
|
showsQuickActions: showsQuickActions,
|
|
durationMinutes: endMinutes - startMinutes,
|
|
);
|
|
}
|