533 lines
16 KiB
Dart
533 lines
16 KiB
Dart
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
/// Tests persistent SQLite composition behavior in the Flutter app.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
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/focus_flow_app.dart';
|
|
import 'package:focus_flow_flutter/app/persistent_scheduler_composition.dart';
|
|
import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart';
|
|
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
|
|
|
/// Runs persistent composition smoke tests.
|
|
void main() {
|
|
testWidgets(
|
|
'persistent app starts without seeded demo tasks and reopens capture',
|
|
(tester) async {
|
|
final path = await _runAsync(tester, _tempDatabasePath);
|
|
final composition = await _runAsync(
|
|
tester,
|
|
() => PersistentSchedulerComposition.open(
|
|
sqlitePath: path,
|
|
selectedDate: _date,
|
|
clock: FixedClock(_instant(8)),
|
|
),
|
|
);
|
|
|
|
await tester.pumpWidget(FocusFlowApp(composition: composition));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
expect(find.text('January 2, 2026'), findsOneWidget);
|
|
expect(find.text('Clean coffee maker'), findsNothing);
|
|
expect(find.text('Water plants'), findsNothing);
|
|
|
|
final commandController = composition.createCommandController(
|
|
refreshReads: () async {},
|
|
selectedDate: () => composition.initialDate,
|
|
);
|
|
addTearDown(commandController.dispose);
|
|
await tester.runAsync(
|
|
() => commandController.quickCaptureToBacklog(
|
|
'Captured from Flutter test',
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump();
|
|
await tester.runAsync(composition.dispose);
|
|
|
|
final reopened = await _runAsync(
|
|
tester,
|
|
() => PersistentSchedulerComposition.open(
|
|
sqlitePath: path,
|
|
selectedDate: _date,
|
|
clock: FixedClock(_instant(8, 10)),
|
|
),
|
|
);
|
|
addTearDown(() => tester.runAsync(reopened.dispose));
|
|
final backlog = await _runAsync(
|
|
tester,
|
|
() => reopened.managementUseCases.getBacklog(
|
|
GetBacklogRequest(
|
|
context: reopened.context('flutter-read-backlog'),
|
|
sortKey: BacklogSortKey.age,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(backlog.requireValue.items, hasLength(1));
|
|
expect(
|
|
backlog.requireValue.items.single.task.title,
|
|
'Captured from Flutter test',
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'selected future-day schedule completion and uncomplete persist across reopen',
|
|
(tester) async {
|
|
final path = await _runAsync(tester, _tempDatabasePath);
|
|
final tomorrow = _date.addDays(1);
|
|
const title = 'Schedule tomorrow from selected date';
|
|
|
|
final selectedDate = tomorrow;
|
|
var composition = await _openComposition(
|
|
tester,
|
|
path: path,
|
|
selectedDate: selectedDate,
|
|
clock: FixedClock(_instant(8)),
|
|
);
|
|
var commandController = _commandController(
|
|
composition,
|
|
selectedDate: selectedDate,
|
|
operationIdPrefix: 'future-schedule',
|
|
now: () => _instant(8),
|
|
);
|
|
await tester.runAsync(
|
|
() => commandController.quickCaptureToBacklog(title),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
|
|
final backlogBeforeSchedule = await _readBacklog(tester, composition);
|
|
final captured = backlogBeforeSchedule.items.single.task;
|
|
await tester.runAsync(
|
|
() => commandController.scheduleBacklogItem(
|
|
taskId: captured.id,
|
|
durationMinutes: 30,
|
|
expectedUpdatedAt: captured.updatedAt,
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
expect((await _readBacklog(tester, composition)).items, isEmpty);
|
|
|
|
commandController.dispose();
|
|
await _closeComposition(tester, composition);
|
|
|
|
composition = await _openComposition(
|
|
tester,
|
|
path: path,
|
|
selectedDate: selectedDate,
|
|
clock: FixedClock(_instant(8, 10)),
|
|
);
|
|
var scheduled = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
tomorrow,
|
|
title,
|
|
);
|
|
expect(scheduled.taskStatus, TaskStatus.planned);
|
|
expect(CivilDate.fromDateTime(scheduled.start!.toUtc()), tomorrow);
|
|
|
|
commandController = _commandController(
|
|
composition,
|
|
selectedDate: selectedDate,
|
|
operationIdPrefix: 'future-complete',
|
|
now: () => DateTime.utc(2026, 1, 4, 8, 5),
|
|
);
|
|
await tester.runAsync(
|
|
() => commandController.completeTask(
|
|
taskId: scheduled.id,
|
|
taskType: scheduled.taskType,
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
commandController.dispose();
|
|
await _closeComposition(tester, composition);
|
|
|
|
composition = await _openComposition(
|
|
tester,
|
|
path: path,
|
|
selectedDate: selectedDate,
|
|
clock: FixedClock(_instant(8, 20)),
|
|
);
|
|
final completed = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
tomorrow,
|
|
title,
|
|
);
|
|
expect(completed.taskStatus, TaskStatus.completed);
|
|
expect(completed.item.completedAt, isNotNull);
|
|
final completedData = TodayScreenData.fromTodayState(
|
|
await _readToday(tester, composition, tomorrow),
|
|
now: DateTime.utc(2026, 1, 4, 8, 20),
|
|
);
|
|
final completedCard = completedData.cards.singleWhere(
|
|
(card) => card.title == title,
|
|
);
|
|
expect(completedCard.completionDateContextRequired, isTrue);
|
|
expect(completedCard.completedTimeText, 'Jan 4, 8:05 AM');
|
|
expect(completedCard.subtitle, 'Completed at: Jan 4, 8:05 AM');
|
|
|
|
commandController = _commandController(
|
|
composition,
|
|
selectedDate: selectedDate,
|
|
operationIdPrefix: 'future-uncomplete',
|
|
now: () => DateTime.utc(2026, 1, 4, 8, 10),
|
|
);
|
|
await tester.runAsync(
|
|
() => commandController.uncompleteTask(taskId: completed.id),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
commandController.dispose();
|
|
await _closeComposition(tester, composition);
|
|
|
|
composition = await _openComposition(
|
|
tester,
|
|
path: path,
|
|
selectedDate: selectedDate,
|
|
clock: FixedClock(_instant(8, 30)),
|
|
);
|
|
addTearDown(() => tester.runAsync(composition.dispose));
|
|
final reopened = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
tomorrow,
|
|
title,
|
|
);
|
|
expect(reopened.taskStatus, TaskStatus.planned);
|
|
expect(reopened.item.completedAt, isNull);
|
|
expect((await _readBacklog(tester, composition)).items, isEmpty);
|
|
},
|
|
);
|
|
|
|
testWidgets('past task push destinations persist across reopen', (
|
|
tester,
|
|
) async {
|
|
final path = await _runAsync(tester, _tempDatabasePath);
|
|
const pushNextTitle = 'Push next persistence';
|
|
const pushTomorrowTitle = 'Push tomorrow persistence';
|
|
const pushBacklogTitle = 'Push backlog persistence';
|
|
var commandNow = _instant(8);
|
|
|
|
var composition = await _openComposition(
|
|
tester,
|
|
path: path,
|
|
selectedDate: _date,
|
|
clock: FixedClock(commandNow),
|
|
);
|
|
var commandController = _commandController(
|
|
composition,
|
|
selectedDate: _date,
|
|
operationIdPrefix: 'past-push',
|
|
now: () => commandNow,
|
|
);
|
|
|
|
await _captureAndSchedule(
|
|
tester,
|
|
composition,
|
|
commandController,
|
|
pushNextTitle,
|
|
);
|
|
await _captureAndSchedule(
|
|
tester,
|
|
composition,
|
|
commandController,
|
|
pushTomorrowTitle,
|
|
);
|
|
await _captureAndSchedule(
|
|
tester,
|
|
composition,
|
|
commandController,
|
|
pushBacklogTitle,
|
|
);
|
|
|
|
commandNow = _instant(12);
|
|
final pastCards =
|
|
TodayScreenData.fromTodayState(
|
|
await _readToday(tester, composition, _date),
|
|
now: commandNow,
|
|
).cards.where(
|
|
(card) => {
|
|
pushNextTitle,
|
|
pushTomorrowTitle,
|
|
pushBacklogTitle,
|
|
}.contains(card.title),
|
|
);
|
|
expect(pastCards, hasLength(3));
|
|
expect(
|
|
pastCards.map((card) => card.showPastTaskPushButton),
|
|
everyElement(isTrue),
|
|
);
|
|
|
|
final pushNextItem = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
_date,
|
|
pushNextTitle,
|
|
);
|
|
await tester.runAsync(
|
|
() => commandController.pushTaskToNextAvailableSlot(
|
|
taskId: pushNextItem.id,
|
|
expectedUpdatedAt: pushNextItem.item.updatedAt,
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
|
|
final pushTomorrowItem = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
_date,
|
|
pushTomorrowTitle,
|
|
);
|
|
await tester.runAsync(
|
|
() => commandController.pushTaskToTomorrow(
|
|
taskId: pushTomorrowItem.id,
|
|
expectedUpdatedAt: pushTomorrowItem.item.updatedAt,
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
|
|
final pushBacklogItem = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
_date,
|
|
pushBacklogTitle,
|
|
);
|
|
await tester.runAsync(
|
|
() => commandController.pushTaskToBacklog(
|
|
taskId: pushBacklogItem.id,
|
|
expectedUpdatedAt: pushBacklogItem.item.updatedAt,
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
|
|
final pushedNext = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
_date,
|
|
pushNextTitle,
|
|
);
|
|
expect(pushedNext.start, _instant(12));
|
|
expect(pushedNext.end, _instant(12, 30));
|
|
|
|
commandNow = _instant(12, 45);
|
|
await tester.runAsync(
|
|
() => commandController.completeTask(
|
|
taskId: pushedNext.id,
|
|
taskType: pushedNext.taskType,
|
|
expectedUpdatedAt: pushedNext.item.updatedAt,
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
commandController.dispose();
|
|
await _closeComposition(tester, composition);
|
|
|
|
composition = await _openComposition(
|
|
tester,
|
|
path: path,
|
|
selectedDate: _date,
|
|
clock: FixedClock(_instant(13)),
|
|
);
|
|
addTearDown(() => tester.runAsync(composition.dispose));
|
|
|
|
final reopenedNext = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
_date,
|
|
pushNextTitle,
|
|
);
|
|
expect(reopenedNext.taskStatus, TaskStatus.completed);
|
|
expect(reopenedNext.start, _instant(12));
|
|
expect(reopenedNext.item.completedAt, _instant(12, 45));
|
|
final reopenedNextCard = TodayScreenData.fromTodayState(
|
|
await _readToday(tester, composition, _date),
|
|
now: _instant(13),
|
|
).cards.singleWhere((card) => card.title == pushNextTitle);
|
|
expect(reopenedNextCard.showPastTaskPushButton, isFalse);
|
|
|
|
final reopenedTomorrow = await _readTodayItem(
|
|
tester,
|
|
composition,
|
|
_date.addDays(1),
|
|
pushTomorrowTitle,
|
|
);
|
|
expect(reopenedTomorrow.taskStatus, TaskStatus.planned);
|
|
expect(reopenedTomorrow.start, DateTime.utc(2026, 1, 3));
|
|
expect(reopenedTomorrow.end, DateTime.utc(2026, 1, 3, 0, 30));
|
|
|
|
final reopenedBacklog = await _readBacklog(tester, composition);
|
|
final backlogTask = reopenedBacklog.items.single.task;
|
|
expect(backlogTask.title, pushBacklogTitle);
|
|
expect(backlogTask.scheduledStart, isNull);
|
|
expect(backlogTask.backlogEnteredAt, _instant(12));
|
|
expect(
|
|
backlogTask.backlogEnteredAtProvenance,
|
|
Task.backlogEnteredAtProvenanceMovedToBacklog,
|
|
);
|
|
expect(
|
|
(await _readToday(
|
|
tester,
|
|
composition,
|
|
_date,
|
|
)).timelineItems.map((item) => item.item.displayTitle),
|
|
isNot(contains(pushBacklogTitle)),
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Fixed date used by persistent Flutter tests.
|
|
final _date = CivilDate(2026, 1, 2);
|
|
|
|
/// Returns an instant on the fixed lifecycle date.
|
|
DateTime _instant(int hour, [int minute = 0]) {
|
|
return DateTime.utc(_date.year, _date.month, _date.day, hour, minute);
|
|
}
|
|
|
|
/// Returns a fresh temporary SQLite path.
|
|
Future<String> _tempDatabasePath() async {
|
|
final directory = await Directory.systemTemp.createTemp(
|
|
'focus-flow-flutter-persistence-',
|
|
);
|
|
addTearDown(() => directory.delete(recursive: true));
|
|
return '${directory.path}/scheduler.sqlite';
|
|
}
|
|
|
|
/// Opens a persistent composition for a test database.
|
|
Future<PersistentSchedulerComposition> _openComposition(
|
|
WidgetTester tester, {
|
|
required String path,
|
|
required CivilDate selectedDate,
|
|
required Clock clock,
|
|
}) {
|
|
return _runAsync(
|
|
tester,
|
|
() => PersistentSchedulerComposition.open(
|
|
sqlitePath: path,
|
|
selectedDate: selectedDate,
|
|
clock: clock,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Closes [composition] outside the widget-test fake async zone.
|
|
Future<void> _closeComposition(
|
|
WidgetTester tester,
|
|
PersistentSchedulerComposition composition,
|
|
) async {
|
|
await tester.runAsync(composition.dispose);
|
|
}
|
|
|
|
/// Reads the backlog through the management use case.
|
|
Future<BacklogQueryResult> _readBacklog(
|
|
WidgetTester tester,
|
|
PersistentSchedulerComposition composition,
|
|
) async {
|
|
final result = await _runAsync(
|
|
tester,
|
|
() => composition.managementUseCases.getBacklog(
|
|
GetBacklogRequest(
|
|
context: composition.context('flutter-read-backlog'),
|
|
sortKey: BacklogSortKey.age,
|
|
),
|
|
),
|
|
);
|
|
return result.requireValue;
|
|
}
|
|
|
|
/// Builds a deterministic command controller for persistence lifecycle tests.
|
|
SchedulerCommandController _commandController(
|
|
PersistentSchedulerComposition composition, {
|
|
required CivilDate selectedDate,
|
|
required String operationIdPrefix,
|
|
required DateTime Function() now,
|
|
}) {
|
|
return SchedulerCommandController(
|
|
commands: composition.commandUseCases,
|
|
contextFor: composition.context,
|
|
selectedDate: () => selectedDate,
|
|
refreshReads: () async {},
|
|
quickCaptureProjectId: composition.defaultProjectId,
|
|
operationIdPrefix: operationIdPrefix,
|
|
now: now,
|
|
);
|
|
}
|
|
|
|
/// Captures [title] into Backlog and schedules it on the selected test date.
|
|
Future<void> _captureAndSchedule(
|
|
WidgetTester tester,
|
|
PersistentSchedulerComposition composition,
|
|
SchedulerCommandController commandController,
|
|
String title,
|
|
) async {
|
|
await tester.runAsync(() => commandController.quickCaptureToBacklog(title));
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
|
|
final captured = (await _readBacklog(
|
|
tester,
|
|
composition,
|
|
)).items.singleWhere((item) => item.task.title == title).task;
|
|
await tester.runAsync(
|
|
() => commandController.scheduleBacklogItem(
|
|
taskId: captured.id,
|
|
durationMinutes: 30,
|
|
expectedUpdatedAt: captured.updatedAt,
|
|
),
|
|
);
|
|
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
|
}
|
|
|
|
/// Reads one Today timeline item by [title].
|
|
Future<TodayTimelineItem> _readTodayItem(
|
|
WidgetTester tester,
|
|
PersistentSchedulerComposition composition,
|
|
CivilDate date,
|
|
String title,
|
|
) async {
|
|
final state = await _readToday(tester, composition, date);
|
|
return state.timelineItems.singleWhere(
|
|
(item) => item.item.displayTitle == title,
|
|
);
|
|
}
|
|
|
|
/// Reads Today state for [date].
|
|
Future<TodayState> _readToday(
|
|
WidgetTester tester,
|
|
PersistentSchedulerComposition composition,
|
|
CivilDate date,
|
|
) async {
|
|
final result = await _runAsync(
|
|
tester,
|
|
() => composition.todayQuery.execute(
|
|
GetTodayStateRequest(
|
|
context: composition.context(
|
|
'flutter-read-today-${date.toIsoString()}',
|
|
),
|
|
date: date,
|
|
includeNextFlexibleItem: true,
|
|
),
|
|
),
|
|
);
|
|
return result.requireValue;
|
|
}
|
|
|
|
/// Runs real async work outside the widget-test fake async zone.
|
|
Future<T> _runAsync<T>(
|
|
WidgetTester tester,
|
|
Future<T> Function() callback,
|
|
) async {
|
|
final result = await tester.runAsync(callback);
|
|
if (result == null) {
|
|
throw StateError('runAsync returned null.');
|
|
}
|
|
return result;
|
|
}
|