// 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()); 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()); 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()); 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)), ); final 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()); 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()); 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), ); final 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()); final pushTomorrowItem = await _readTodayItem( tester, composition, _date, pushTomorrowTitle, ); await tester.runAsync( () => commandController.pushTaskToTomorrow( taskId: pushTomorrowItem.id, expectedUpdatedAt: pushTomorrowItem.item.updatedAt, ), ); expect(commandController.state, isA()); final pushBacklogItem = await _readTodayItem( tester, composition, _date, pushBacklogTitle, ); await tester.runAsync( () => commandController.pushTaskToBacklog( taskId: pushBacklogItem.id, expectedUpdatedAt: pushBacklogItem.item.updatedAt, ), ); expect(commandController.state, isA()); 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()); 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 _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 _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 _closeComposition( WidgetTester tester, PersistentSchedulerComposition composition, ) async { await tester.runAsync(composition.dispose); } /// Reads the backlog through the management use case. Future _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 _captureAndSchedule( WidgetTester tester, PersistentSchedulerComposition composition, SchedulerCommandController commandController, String title, ) async { await tester.runAsync(() => commandController.quickCaptureToBacklog(title)); expect(commandController.state, isA()); 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()); } /// Reads one Today timeline item by [title]. Future _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 _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 _runAsync( WidgetTester tester, Future Function() callback, ) async { final result = await tester.runAsync(callback); if (result == null) { throw StateError('runAsync returned null.'); } return result; }