focus-flow/apps/focus_flow_flutter/test/persistent_composition_test.dart
pyr0ball 6f0da3c8aa feat(ui): wire Settings dialog to persisted OwnerSettings
Adds a getOwnerSettings read query to scheduler_core (previously only
an internal helper existed) and an updateOwnerSettings command on the
Flutter command controller, then builds a shared Settings dialog
surfacing timezone, day window, compact mode, and backlog staleness
thresholds. Both existing no-op entry points (sidebar nav item, top
bar button) now open the same dialog and persist edits through the
existing OwnerSettings/BacklogStalenessSettings backend.

Per the design spec (circuitforge-plans/focus-flow/superpowers/specs/
2026-07-07-settings-screen-design.md), the compact-mode toggle
persists but is disclosed in-UI as not yet affecting Today rendering,
since that wiring is separate scope (#11).

Closes: eva/focus-flow#16
2026-07-07 14:38:16 -07:00

634 lines
19 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/app/runtime/focus_flow_runtime_config.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('ui timezone config controls local startup date only', (
tester,
) async {
final path = await _runAsync(tester, _tempDatabasePath);
final composition = await _runAsync(
tester,
() => PersistentSchedulerComposition.open(
sqlitePath: path,
clock: FixedClock(DateTime.utc(2026, 1, 3, 2)),
uiTimeZone: FocusFlowUiTimeZone.tryParse(
'pst',
referenceInstant: DateTime.utc(2026, 1, 3, 2),
),
),
);
addTearDown(() => tester.runAsync(composition.dispose));
expect(composition.initialDate, CivilDate(2026, 1, 2));
expect(
composition.context('timezone-context').ownerTimeZone.timeZoneId,
'PST',
);
final storedSettings = await _runAsync(
tester,
() => composition.runtime.applicationStore.read<OwnerSettings?>(
action: (repositories) async {
return ApplicationResult.success(
await repositories.ownerSettings.findByOwnerId('owner-1'),
);
},
),
);
expect(storedSettings.requireValue?.timeZoneId, 'UTC');
});
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)),
);
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<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),
);
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<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)),
);
});
testWidgets('push to tomorrow uses ui-local current date at UTC rollover', (
tester,
) async {
final path = await _runAsync(tester, _tempDatabasePath);
final currentLocalDate = CivilDate(2026, 7, 4);
final tomorrow = currentLocalDate.addDays(1);
final dayAfterTomorrow = currentLocalDate.addDays(2);
final commandNow = DateTime.utc(2026, 7, 5, 2);
const title = 'Push tomorrow from evening PST';
final composition = await _openComposition(
tester,
path: path,
selectedDate: currentLocalDate,
clock: FixedClock(commandNow),
uiTimeZone: FocusFlowUiTimeZone.tryParse(
'pst',
referenceInstant: commandNow,
),
);
addTearDown(() => tester.runAsync(composition.dispose));
final commandController = _commandController(
composition,
selectedDate: currentLocalDate,
operationIdPrefix: 'local-tomorrow-rollover',
now: () => commandNow,
);
addTearDown(commandController.dispose);
await _captureAndSchedule(tester, composition, commandController, title);
final scheduled = await _readTodayItem(
tester,
composition,
currentLocalDate,
title,
);
await tester.runAsync(
() => commandController.pushTaskToTomorrow(
taskId: scheduled.id,
expectedUpdatedAt: scheduled.item.updatedAt,
),
);
expect(commandController.state, isA<SchedulerCommandSuccess>());
final pushed = await _readTodayItem(tester, composition, tomorrow, title);
expect(pushed.taskStatus, TaskStatus.planned);
expect(pushed.start, DateTime.utc(2026, 7, 5, 7));
expect(pushed.end, DateTime.utc(2026, 7, 5, 7, 30));
expect(
(await _readToday(
tester,
composition,
dayAfterTomorrow,
)).timelineItems.map((item) => item.item.displayTitle),
isNot(contains(title)),
);
});
}
/// 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,
FocusFlowUiTimeZone? uiTimeZone,
}) {
return _runAsync(
tester,
() => PersistentSchedulerComposition.open(
sqlitePath: path,
selectedDate: selectedDate,
clock: clock,
uiTimeZone: uiTimeZone,
),
);
}
/// 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,
management: composition.managementUseCases,
contextFor: composition.context,
selectedDate: () => selectedDate,
refreshReads: () async {},
quickCaptureProjectId: composition.defaultProjectId,
operationIdPrefix: operationIdPrefix,
currentDateFor: composition.uiTimeZone.toLocalCivilDate,
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;
}