forked from eva/focus-flow
test(backlog): complete board validation handoff
This commit is contained in:
parent
3b950358c2
commit
bfba203d1e
21 changed files with 298 additions and 11 deletions
|
|
@ -12,6 +12,9 @@ Included documents cover:
|
||||||
ADRs, public API baseline, and requirements traceability matrix.
|
ADRs, public API baseline, and requirements traceability matrix.
|
||||||
- UI Plan 1, the implemented compact Today timeline mockup plan, including its
|
- UI Plan 1, the implemented compact Today timeline mockup plan, including its
|
||||||
source mockups and completed block handoff notes.
|
source mockups and completed block handoff notes.
|
||||||
|
- UI Plan 2, the implemented Backlog board plan, including public scheduler
|
||||||
|
read models, command-backed drawer actions, board summary, and validation
|
||||||
|
handoff notes.
|
||||||
- Persistence Plan 1, the implemented SQLite runtime persistence slice for
|
- Persistence Plan 1, the implemented SQLite runtime persistence slice for
|
||||||
normal Flutter startup and close/reopen task lifecycle proof.
|
normal Flutter startup and close/reopen task lifecycle proof.
|
||||||
- Date Selection 01, the completed day navigation and date picker plan for the
|
- Date Selection 01, the completed day navigation and date picker plan for the
|
||||||
|
|
|
||||||
|
|
@ -219,3 +219,26 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
|
||||||
8. The shared application-command change detector now includes Backlog metadata
|
8. The shared application-command change detector now includes Backlog metadata
|
||||||
such as `backlogTags`, so metadata-only commands are persisted even when the
|
such as `backlogTags`, so metadata-only commands are persisted even when the
|
||||||
command clock equals the task's previous `updatedAt`.
|
command clock equals the task's previous `updatedAt`.
|
||||||
|
|
||||||
|
## Block 09 Validation and Handoff
|
||||||
|
|
||||||
|
1. Added final widget coverage for the default Backlog shell controls, empty
|
||||||
|
New Task title validation, and responsive no-overflow smoke checks at 1672,
|
||||||
|
1440, 1280, and 1100 px desktop widths.
|
||||||
|
2. Added a SQLite schema-column regression test and kept Drift table DSL input
|
||||||
|
files out of the custom runtime coverage denominator. The fresh coverage
|
||||||
|
gate reports 80.34% package `lib/` line coverage.
|
||||||
|
3. Preserved the forbidden-import boundary test so Flutter widgets and
|
||||||
|
controllers continue to use public scheduler APIs instead of scheduler
|
||||||
|
internals, Drift, SQLite adapters, notification internals, backup/export
|
||||||
|
internals, or direct OS APIs outside approved runtime boundaries.
|
||||||
|
4. Final validation passed with:
|
||||||
|
- `scripts/test.sh`
|
||||||
|
- `cd apps/focus_flow_flutter && flutter analyze`
|
||||||
|
- `cd apps/focus_flow_flutter && flutter test`
|
||||||
|
5. Manual live UI review was not possible in this session because the local X11
|
||||||
|
display was refusing new clients with `Maximum number of clients reached`.
|
||||||
|
The diagnosed cause was many duplicate `xbindkeys` clients, not a Flutter
|
||||||
|
build failure.
|
||||||
|
6. Deferred items remain limited to the approved metadata scope: real notes,
|
||||||
|
freeform tags, project edits, and the Backlog drawer `View day` shortcut.
|
||||||
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
|
@ -12,9 +12,15 @@ on-disk SQLite database and bootstraps owner settings plus the default Home
|
||||||
project when they are missing.
|
project when they are missing.
|
||||||
|
|
||||||
The compact Today screen can toggle completion for currently rendered task
|
The compact Today screen can toggle completion for currently rendered task
|
||||||
cards. Backlog widgets and quick-capture controls exist for tests and focused
|
cards. The sidebar Backlog navigation opens the board surface backed by public
|
||||||
composition paths, but the sidebar Backlog navigation remains out of scope for
|
scheduler application APIs. Backlog supports the four board buckets, search,
|
||||||
this screen.
|
filters, sort/group controls, summary data, the right-anchored detail drawer,
|
||||||
|
task creation, scheduling, Break Up child-task creation, Someday tagging, and
|
||||||
|
non-destructive archive/remove from active Backlog.
|
||||||
|
|
||||||
|
Backlog notes, project edits, and freeform tag edits intentionally remain
|
||||||
|
read-only or non-mutating placeholders until the deferred metadata migration and
|
||||||
|
application command are implemented.
|
||||||
|
|
||||||
## Runtime Data
|
## Runtime Data
|
||||||
|
|
||||||
|
|
@ -102,13 +108,13 @@ boundary.
|
||||||
|
|
||||||
## Intentionally No-op
|
## Intentionally No-op
|
||||||
|
|
||||||
- Sidebar navigation.
|
|
||||||
- Date navigation and calendar buttons.
|
- Date navigation and calendar buttons.
|
||||||
- Compact/Normal toggle.
|
- Compact/Normal toggle.
|
||||||
- Settings button.
|
- Settings button.
|
||||||
- Show upcoming.
|
- Show upcoming.
|
||||||
- Timeline card action icons.
|
- Timeline card action icons.
|
||||||
- Modal Push, Move to Backlog, and Break up buttons.
|
- Timeline modal Break up.
|
||||||
|
- Backlog drawer project selector, add-tag control, and View day shortcut.
|
||||||
|
|
||||||
## Persistence Proof
|
## Persistence Proof
|
||||||
|
|
||||||
|
|
@ -171,7 +177,7 @@ Run these from `apps/focus_flow_flutter/`.
|
||||||
|
|
||||||
## Next Plans
|
## Next Plans
|
||||||
|
|
||||||
- Wire visible Backlog navigation and quick-capture UI into the main shell.
|
- Add migration-backed notes, project edits, and freeform tags for Backlog
|
||||||
- Wire functional Push, Move to Backlog, and Break up actions.
|
metadata.
|
||||||
- Add real navigation/settings.
|
- Add real navigation/settings.
|
||||||
- Add Shield/Recovery only in a later scope.
|
- Add Shield/Recovery only in a later scope.
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,19 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.byKey(const ValueKey('backlog-search-field')), findsOneWidget);
|
expect(find.byKey(const ValueKey('backlog-search-field')), findsOneWidget);
|
||||||
|
expect(find.text('Search backlog...'), findsOneWidget);
|
||||||
|
expect(find.text('Filters'), findsOneWidget);
|
||||||
|
expect(find.text('Sort: Custom'), findsOneWidget);
|
||||||
|
expect(find.text('Group: None'), findsOneWidget);
|
||||||
expect(find.text('Board'), findsOneWidget);
|
expect(find.text('Board'), findsOneWidget);
|
||||||
expect(find.text('New Task'), findsOneWidget);
|
expect(find.text('New Task'), findsOneWidget);
|
||||||
expect(find.text('Backlog summary'), findsOneWidget);
|
expect(find.text('Backlog summary'), findsOneWidget);
|
||||||
expect(find.text('24 tasks'), findsOneWidget);
|
expect(find.text('24 tasks'), findsOneWidget);
|
||||||
|
expect(find.text('Do Next'), findsOneWidget);
|
||||||
|
expect(find.text('Need Time Block'), findsOneWidget);
|
||||||
|
expect(find.text('Break Up First'), findsOneWidget);
|
||||||
|
expect(find.text('Not Now'), findsOneWidget);
|
||||||
|
expect(find.text('Review Q3 budget'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('seeded Today renders backend-backed compact mockup data', (
|
testWidgets('seeded Today renders backend-backed compact mockup data', (
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,31 @@ void main() {
|
||||||
expect(find.text('Draft outline'), findsOneWidget);
|
expect(find.text('Draft outline'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('new task dialog validates an empty title without writing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = _BacklogCommandHarness.empty();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
|
||||||
|
await harness.boardController.load();
|
||||||
|
await _pumpBacklogScreen(
|
||||||
|
tester,
|
||||||
|
harness.boardController,
|
||||||
|
commandController: harness.commandController,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-new-task-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('backlog-new-task-create-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Title is required.'), findsOneWidget);
|
||||||
|
expect(harness.commandController.state, isA<SchedulerCommandIdle>());
|
||||||
|
expect(harness.store.currentTasks, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('schedule action uses command controller and refreshes board', (
|
testWidgets('schedule action uses command controller and refreshes board', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -570,6 +595,51 @@ void main() {
|
||||||
expect(requests.last.boardFilters.isEmpty, isTrue);
|
expect(requests.last.boardFilters.isEmpty, isTrue);
|
||||||
expect(find.text('Filters'), findsOneWidget);
|
expect(find.text('Filters'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('layout avoids overflow at review desktop widths', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final item = _boardItemWithChildren();
|
||||||
|
final board = _boardWithItem(item);
|
||||||
|
final selectedDates = SelectedDateController(
|
||||||
|
initialDate: CivilDate(2026, 7, 7),
|
||||||
|
);
|
||||||
|
final controller = BacklogBoardController(
|
||||||
|
readBoard: (_) async => ApplicationResult.success(board),
|
||||||
|
readTaskDetail: (taskId, localDate) async {
|
||||||
|
return ApplicationResult.success(_detailFor(item));
|
||||||
|
},
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: (date) => date.toIsoString(),
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(selectedDates.dispose);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
for (final surfaceSize in _reviewSurfaceSizes) {
|
||||||
|
await _pumpBacklogScreen(
|
||||||
|
tester,
|
||||||
|
controller,
|
||||||
|
surfaceSize: surfaceSize,
|
||||||
|
frameSize: Size(surfaceSize.width - 24, surfaceSize.height - 40),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.takeException(),
|
||||||
|
isNull,
|
||||||
|
reason: 'Unexpected layout exception at $surfaceSize.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await controller.selectTask('plan-week');
|
||||||
|
await _pumpBacklogScreen(
|
||||||
|
tester,
|
||||||
|
controller,
|
||||||
|
surfaceSize: const Size(1100, 760),
|
||||||
|
frameSize: const Size(1076, 720),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
||||||
|
|
@ -577,16 +647,18 @@ Future<void> _pumpBacklogScreen(
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
BacklogBoardController controller, {
|
BacklogBoardController controller, {
|
||||||
SchedulerCommandController? commandController,
|
SchedulerCommandController? commandController,
|
||||||
|
Size surfaceSize = const Size(1320, 820),
|
||||||
|
Size frameSize = const Size(1300, 780),
|
||||||
}) async {
|
}) async {
|
||||||
await tester.binding.setSurfaceSize(const Size(1320, 820));
|
await tester.binding.setSurfaceSize(surfaceSize);
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: FocusFlowTheme.dark(),
|
theme: FocusFlowTheme.dark(),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: SizedBox(
|
body: SizedBox(
|
||||||
width: 1300,
|
width: frameSize.width,
|
||||||
height: 780,
|
height: frameSize.height,
|
||||||
child: BacklogBoardScreen(
|
child: BacklogBoardScreen(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
commandController: commandController,
|
commandController: commandController,
|
||||||
|
|
@ -598,6 +670,14 @@ Future<void> _pumpBacklogScreen(
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Desktop widths used for final Backlog layout smoke tests.
|
||||||
|
const _reviewSurfaceSizes = [
|
||||||
|
Size(1672, 940),
|
||||||
|
Size(1440, 860),
|
||||||
|
Size(1280, 820),
|
||||||
|
Size(1100, 760),
|
||||||
|
];
|
||||||
|
|
||||||
/// Lets unawaited UI command futures finish and repaint their final state.
|
/// Lets unawaited UI command futures finish and repaint their final state.
|
||||||
Future<void> _settleAsyncUi(WidgetTester tester) async {
|
Future<void> _settleAsyncUi(WidgetTester tester) async {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,159 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('opens schema version 2 with expected columns', () async {
|
||||||
|
final db = SchedulerDb(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
Future<List<String>> columnsFor(String tableName) async {
|
||||||
|
final rows =
|
||||||
|
await db.customSelect('PRAGMA table_info("$tableName")').get();
|
||||||
|
return rows.map((row) => row.data['name']! as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await columnsFor('tasks'), <String>[
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'title',
|
||||||
|
'project_id',
|
||||||
|
'parent_id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'reward',
|
||||||
|
'difficulty',
|
||||||
|
'duration_minutes',
|
||||||
|
'scheduled_start_utc',
|
||||||
|
'scheduled_end_utc',
|
||||||
|
'actual_start_utc',
|
||||||
|
'actual_end_utc',
|
||||||
|
'completed_at_utc',
|
||||||
|
'backlog_tags_json',
|
||||||
|
'reminder_override',
|
||||||
|
'stats_json',
|
||||||
|
'backlog_entered_at_utc',
|
||||||
|
'backlog_entered_provenance',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('task_activities'), <String>[
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'task_id',
|
||||||
|
'project_id',
|
||||||
|
'operation_id',
|
||||||
|
'code',
|
||||||
|
'occurred_at_utc',
|
||||||
|
'metadata_json',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('projects'), <String>[
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'name',
|
||||||
|
'color_key',
|
||||||
|
'default_priority',
|
||||||
|
'default_reward',
|
||||||
|
'default_difficulty',
|
||||||
|
'default_reminder_profile',
|
||||||
|
'default_duration_minutes',
|
||||||
|
'archived_at_utc',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('project_statistics'), <String>[
|
||||||
|
'project_id',
|
||||||
|
'owner_id',
|
||||||
|
'completed_task_count',
|
||||||
|
'duration_minute_counts_json',
|
||||||
|
'completion_time_bucket_counts_json',
|
||||||
|
'total_pushes_before_completion',
|
||||||
|
'completed_after_push_count',
|
||||||
|
'reward_counts_json',
|
||||||
|
'difficulty_counts_json',
|
||||||
|
'reminder_profile_counts_json',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('locked_blocks'), <String>[
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'name',
|
||||||
|
'date',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'recurrence_json',
|
||||||
|
'hidden_by_default',
|
||||||
|
'project_id',
|
||||||
|
'archived_at_utc',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('locked_overrides'), <String>[
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'locked_block_id',
|
||||||
|
'date',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'hidden_by_default',
|
||||||
|
'project_id',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('settings'), <String>[
|
||||||
|
'owner_id',
|
||||||
|
'timezone_id',
|
||||||
|
'day_start_minutes',
|
||||||
|
'day_end_minutes',
|
||||||
|
'compact_mode',
|
||||||
|
'backlog_staleness_json',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('snapshots'), <String>[
|
||||||
|
'id',
|
||||||
|
'owner_id',
|
||||||
|
'captured_at_utc',
|
||||||
|
'operation_name',
|
||||||
|
'source_date',
|
||||||
|
'target_date',
|
||||||
|
'window_json',
|
||||||
|
'tasks_json',
|
||||||
|
'locked_intervals_json',
|
||||||
|
'required_visible_intervals_json',
|
||||||
|
'notices_json',
|
||||||
|
'changes_json',
|
||||||
|
'overlaps_json',
|
||||||
|
'retention_expires_utc',
|
||||||
|
'truncated',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('application_operations'), <String>[
|
||||||
|
'operation_id',
|
||||||
|
'owner_id',
|
||||||
|
'operation_name',
|
||||||
|
'committed_at_utc',
|
||||||
|
]);
|
||||||
|
expect(await columnsFor('notice_acknowledgements'), <String>[
|
||||||
|
'owner_id',
|
||||||
|
'notice_id',
|
||||||
|
'acknowledged_at_utc',
|
||||||
|
'revision',
|
||||||
|
'created_at_utc',
|
||||||
|
'updated_at_utc',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('migrates schema version 1 file and preserves existing rows', () async {
|
test('migrates schema version 1 file and preserves existing rows', () async {
|
||||||
final directory = await Directory.systemTemp.createTemp(
|
final directory = await Directory.systemTemp.createTemp(
|
||||||
'focus-flow-sqlite-migration-',
|
'focus-flow-sqlite-migration-',
|
||||||
|
|
|
||||||
|
|
@ -88,5 +88,18 @@ bool _includeSourceFile(String path) {
|
||||||
final normalized = path.replaceAll('\\', '/');
|
final normalized = path.replaceAll('\\', '/');
|
||||||
return normalized.contains('/packages/') &&
|
return normalized.contains('/packages/') &&
|
||||||
normalized.contains('/lib/') &&
|
normalized.contains('/lib/') &&
|
||||||
!normalized.endsWith('.g.dart');
|
!normalized.endsWith('.g.dart') &&
|
||||||
|
!_isBuilderInputSource(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether [path] is source input for code generation, not runtime code.
|
||||||
|
///
|
||||||
|
/// Drift table declarations are intentionally consumed by the builder and then
|
||||||
|
/// executed through generated database classes. Runtime schema behavior is
|
||||||
|
/// covered by SQLite open and migration tests, while the generated files remain
|
||||||
|
/// excluded by the existing `.g.dart` rule above.
|
||||||
|
bool _isBuilderInputSource(String path) {
|
||||||
|
return path.contains(
|
||||||
|
'/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue