Merge branch 'app-config-file-logging' into task-pushing-01-past-task-controls
This commit is contained in:
commit
cf3370fecd
7 changed files with 516 additions and 5 deletions
|
|
@ -33,6 +33,34 @@ It is not inserted during normal startup.
|
|||
Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`.
|
||||
Scheduler core remains the source of truth for task state and Today ordering.
|
||||
|
||||
## Runtime Config
|
||||
|
||||
The app optionally reads a JSON config file from:
|
||||
|
||||
```text
|
||||
~/ADHD_Scheduler/config.json
|
||||
```
|
||||
|
||||
Override the config path with:
|
||||
|
||||
```sh
|
||||
flutter run -d linux --dart-define=FOCUS_FLOW_CONFIG_PATH=/tmp/focus_flow_config.json
|
||||
```
|
||||
|
||||
Supported optional keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"LogLevel": "debug",
|
||||
"LogfileLocation": "~/ADHD_Scheduler/debug/"
|
||||
}
|
||||
```
|
||||
|
||||
`LogLevel` accepts `trace`, `debug`, `info`, `warn`, or `error`.
|
||||
`LogfileLocation` is treated as a directory, and the app writes
|
||||
`focus_flow.log` inside it. If either key is absent or invalid, file logging is
|
||||
not enabled and the app ignores that setting.
|
||||
|
||||
## Package Boundary
|
||||
|
||||
Allowed app imports include public scheduler APIs such as:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
|||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/selected_date_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
import 'runtime/focus_flow_file_logger.dart';
|
||||
import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel;
|
||||
import 'scheduler_app_composition.dart';
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
required this.commandUseCases,
|
||||
required this.date,
|
||||
required this.readAt,
|
||||
required this.logger,
|
||||
});
|
||||
|
||||
/// Opens the configured SQLite runtime and bootstraps required owner state.
|
||||
|
|
@ -32,20 +34,32 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
String? sqlitePath,
|
||||
Clock? clock,
|
||||
CivilDate? selectedDate,
|
||||
FocusFlowFileLogger? logger,
|
||||
}) async {
|
||||
final resolvedLogger = logger ?? FocusFlowFileLogger.disabled();
|
||||
final resolvedSqlitePath = sqlitePath ?? sqlitePathFromEnvironment();
|
||||
await resolvedLogger.info('Opening SQLite runtime at $resolvedSqlitePath.');
|
||||
final runtime = await SqliteApplicationRuntime.open(
|
||||
path: sqlitePath ?? sqlitePathFromEnvironment(),
|
||||
path: resolvedSqlitePath,
|
||||
);
|
||||
final bootstrap = await runtime.bootstrap();
|
||||
if (bootstrap.isFailure) {
|
||||
await runtime.close();
|
||||
await resolvedLogger.error(
|
||||
'SQLite bootstrap failed.',
|
||||
error: bootstrap.failure!.code.name,
|
||||
);
|
||||
throw StateError(
|
||||
'SQLite bootstrap failed: ${bootstrap.failure!.code.name}',
|
||||
);
|
||||
}
|
||||
await resolvedLogger.debug('SQLite bootstrap completed.');
|
||||
final resolvedClock = clock ?? const SystemClock();
|
||||
final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now());
|
||||
final readAt = resolvedClock.now().toUtc();
|
||||
await resolvedLogger.info(
|
||||
'Persistent scheduler composition opened for ${date.toIsoString()}.',
|
||||
);
|
||||
|
||||
return PersistentSchedulerComposition._(
|
||||
runtime: runtime,
|
||||
|
|
@ -62,6 +76,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
),
|
||||
date: date,
|
||||
readAt: readAt,
|
||||
logger: resolvedLogger,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +113,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
/// Stable read clock instant for operation contexts.
|
||||
final DateTime readAt;
|
||||
|
||||
/// Optional app runtime logger.
|
||||
final FocusFlowFileLogger logger;
|
||||
|
||||
/// Pending close operation, if disposal has started.
|
||||
Future<void>? _disposeFuture;
|
||||
|
||||
|
|
@ -177,6 +195,13 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
/// Closes the persistent runtime.
|
||||
@override
|
||||
Future<void> dispose() {
|
||||
return _disposeFuture ??= runtime.close();
|
||||
return _disposeFuture ??= _dispose();
|
||||
}
|
||||
|
||||
/// Closes runtime resources and records shutdown diagnostics when enabled.
|
||||
Future<void> _dispose() async {
|
||||
await logger.debug('Closing SQLite runtime.');
|
||||
await runtime.close();
|
||||
await logger.info('SQLite runtime closed.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Provides optional FocusFlow runtime file logging.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'focus_flow_runtime_config.dart';
|
||||
|
||||
/// Minimal append-only file logger for FocusFlow runtime diagnostics.
|
||||
final class FocusFlowFileLogger {
|
||||
/// Creates a logger with explicit runtime state.
|
||||
FocusFlowFileLogger._({
|
||||
required this.minimumLevel,
|
||||
required this.logFilePath,
|
||||
required this._now,
|
||||
});
|
||||
|
||||
/// Creates a disabled logger that drops every message.
|
||||
factory FocusFlowFileLogger.disabled({DateTime Function()? now}) {
|
||||
return FocusFlowFileLogger._(
|
||||
minimumLevel: null,
|
||||
logFilePath: null,
|
||||
now: now ?? _utcNow,
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens a logger from [config] when both logging settings are present.
|
||||
static Future<FocusFlowFileLogger> open(
|
||||
FocusFlowRuntimeConfig config, {
|
||||
DateTime Function()? now,
|
||||
String fileName = 'focus_flow.log',
|
||||
}) async {
|
||||
final level = config.logLevel;
|
||||
final location = config.logFileLocation;
|
||||
if (level == null || location == null) {
|
||||
return FocusFlowFileLogger.disabled(now: now);
|
||||
}
|
||||
|
||||
try {
|
||||
final directory = Directory(location);
|
||||
await directory.create(recursive: true);
|
||||
return FocusFlowFileLogger._(
|
||||
minimumLevel: level,
|
||||
logFilePath: _joinPath(directory.path, fileName),
|
||||
now: now ?? _utcNow,
|
||||
);
|
||||
} on Object {
|
||||
return FocusFlowFileLogger.disabled(now: now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum severity that will be written, or null when disabled.
|
||||
final FocusFlowLogLevel? minimumLevel;
|
||||
|
||||
/// Concrete file path receiving appended log lines.
|
||||
final String? logFilePath;
|
||||
|
||||
/// Clock boundary used for log timestamps.
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// Whether this logger currently writes to a file.
|
||||
bool get isEnabled => minimumLevel != null && logFilePath != null;
|
||||
|
||||
/// Writes a trace-level [message] when enabled for trace.
|
||||
Future<void> trace(String message) {
|
||||
return write(FocusFlowLogLevel.trace, message);
|
||||
}
|
||||
|
||||
/// Writes a debug-level [message] when enabled for debug or lower.
|
||||
Future<void> debug(String message) {
|
||||
return write(FocusFlowLogLevel.debug, message);
|
||||
}
|
||||
|
||||
/// Writes an info-level [message] when enabled for info or lower.
|
||||
Future<void> info(String message) {
|
||||
return write(FocusFlowLogLevel.info, message);
|
||||
}
|
||||
|
||||
/// Writes a warning-level [message] when enabled for warning or lower.
|
||||
Future<void> warn(String message, {Object? error, StackTrace? stackTrace}) {
|
||||
return write(
|
||||
FocusFlowLogLevel.warn,
|
||||
message,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes an error-level [message] when file logging is enabled.
|
||||
Future<void> error(String message, {Object? error, StackTrace? stackTrace}) {
|
||||
return write(
|
||||
FocusFlowLogLevel.error,
|
||||
message,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes [message] at [level] when it passes the configured threshold.
|
||||
Future<void> write(
|
||||
FocusFlowLogLevel level,
|
||||
String message, {
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) async {
|
||||
final path = logFilePath;
|
||||
final configuredLevel = minimumLevel;
|
||||
if (path == null ||
|
||||
configuredLevel == null ||
|
||||
!_shouldWrite(level, configuredLevel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..write(_now().toUtc().toIso8601String())
|
||||
..write(' ')
|
||||
..write(level.name.toUpperCase())
|
||||
..write(' ')
|
||||
..write(message.trim());
|
||||
if (error != null) {
|
||||
buffer
|
||||
..write(' | error=')
|
||||
..write(error);
|
||||
}
|
||||
if (stackTrace != null) {
|
||||
buffer
|
||||
..write(' | stackTrace=')
|
||||
..write(stackTrace.toString().replaceAll('\n', r'\n'));
|
||||
}
|
||||
buffer.writeln();
|
||||
|
||||
await File(
|
||||
path,
|
||||
).writeAsString(buffer.toString(), mode: FileMode.append, flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether [level] should be written for [minimumLevel].
|
||||
bool _shouldWrite(FocusFlowLogLevel level, FocusFlowLogLevel minimumLevel) {
|
||||
return _severity(level) >= _severity(minimumLevel);
|
||||
}
|
||||
|
||||
/// Converts [level] to an ordered severity value.
|
||||
int _severity(FocusFlowLogLevel level) {
|
||||
return switch (level) {
|
||||
FocusFlowLogLevel.trace => 0,
|
||||
FocusFlowLogLevel.debug => 1,
|
||||
FocusFlowLogLevel.info => 2,
|
||||
FocusFlowLogLevel.warn => 3,
|
||||
FocusFlowLogLevel.error => 4,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the current UTC timestamp.
|
||||
DateTime _utcNow() => DateTime.now().toUtc();
|
||||
|
||||
/// Joins [base] and [child] with the platform path separator.
|
||||
String _joinPath(String base, String child) {
|
||||
if (base.endsWith('/') || base.endsWith(r'\')) {
|
||||
return '$base$child';
|
||||
}
|
||||
return '$base${Platform.pathSeparator}$child';
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Loads optional FocusFlow runtime configuration from a local JSON file.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// File-backed runtime configuration for optional developer diagnostics.
|
||||
final class FocusFlowRuntimeConfig {
|
||||
/// Creates a runtime configuration with optional logging controls.
|
||||
const FocusFlowRuntimeConfig({this.logLevel, this.logFileLocation});
|
||||
|
||||
/// Loads runtime configuration from [path] or the configured default path.
|
||||
static Future<FocusFlowRuntimeConfig> load({String? path}) async {
|
||||
final configPath = path ?? configPathFromEnvironment();
|
||||
final file = File(_expandHome(configPath));
|
||||
if (!await file.exists()) {
|
||||
return const FocusFlowRuntimeConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(await file.readAsString());
|
||||
if (decoded is Map<String, Object?>) {
|
||||
return fromJson(decoded);
|
||||
}
|
||||
} on Object {
|
||||
return const FocusFlowRuntimeConfig();
|
||||
}
|
||||
|
||||
return const FocusFlowRuntimeConfig();
|
||||
}
|
||||
|
||||
/// Resolves the config file path from a Dart define or the local default.
|
||||
static String configPathFromEnvironment() {
|
||||
const configured = String.fromEnvironment('FOCUS_FLOW_CONFIG_PATH');
|
||||
final trimmed = configured.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
return trimmed;
|
||||
}
|
||||
return defaultConfigPath();
|
||||
}
|
||||
|
||||
/// Returns the default config file path.
|
||||
static String defaultConfigPath() {
|
||||
return _joinPath(_schedulerHomePath(), 'config.json');
|
||||
}
|
||||
|
||||
/// Builds a runtime configuration from decoded JSON fields.
|
||||
static FocusFlowRuntimeConfig fromJson(Map<String, Object?> json) {
|
||||
final level = FocusFlowLogLevel.tryParse(_optionalString(json['LogLevel']));
|
||||
final logLocation = _optionalString(json['LogfileLocation']);
|
||||
return FocusFlowRuntimeConfig(
|
||||
logLevel: level,
|
||||
logFileLocation: logLocation == null ? null : _expandHome(logLocation),
|
||||
);
|
||||
}
|
||||
|
||||
/// Optional minimum level for file logging.
|
||||
final FocusFlowLogLevel? logLevel;
|
||||
|
||||
/// Optional directory where the app should append its debug log file.
|
||||
final String? logFileLocation;
|
||||
|
||||
/// Whether this configuration has enough settings to enable file logging.
|
||||
bool get enablesFileLogging => logLevel != null && logFileLocation != null;
|
||||
}
|
||||
|
||||
/// Supported FocusFlow file logging levels.
|
||||
enum FocusFlowLogLevel {
|
||||
/// Logs every message, including trace-level detail.
|
||||
trace,
|
||||
|
||||
/// Logs debug, info, warning, and error messages.
|
||||
debug,
|
||||
|
||||
/// Logs normal operational information, warnings, and errors.
|
||||
info,
|
||||
|
||||
/// Logs warnings and errors.
|
||||
warn,
|
||||
|
||||
/// Logs only errors.
|
||||
error;
|
||||
|
||||
/// Parses [value] into a supported logging level.
|
||||
static FocusFlowLogLevel? tryParse(String? value) {
|
||||
final normalized = value?.trim().toLowerCase();
|
||||
if (normalized == null || normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final level in FocusFlowLogLevel.values) {
|
||||
if (level.name == normalized) {
|
||||
return level;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads [value] as a non-empty string when present.
|
||||
String? _optionalString(Object? value) {
|
||||
if (value is! String) {
|
||||
return null;
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
/// Returns the local scheduler home directory path.
|
||||
String _schedulerHomePath() {
|
||||
final configured = Platform.environment['SCHEDULER_HOME']?.trim();
|
||||
if (configured != null && configured.isNotEmpty) {
|
||||
return _expandHome(configured);
|
||||
}
|
||||
final home = _homePath();
|
||||
if (home == null) {
|
||||
return 'ADHD_Scheduler';
|
||||
}
|
||||
return _joinPath(home, 'ADHD_Scheduler');
|
||||
}
|
||||
|
||||
/// Resolves the current user's home directory when the platform exposes it.
|
||||
String? _homePath() {
|
||||
final home = Platform.environment['HOME']?.trim();
|
||||
if (home != null && home.isNotEmpty) {
|
||||
return home;
|
||||
}
|
||||
final userProfile = Platform.environment['USERPROFILE']?.trim();
|
||||
if (userProfile != null && userProfile.isNotEmpty) {
|
||||
return userProfile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Expands a leading `~` in [path] using the platform home directory.
|
||||
String _expandHome(String path) {
|
||||
if (path == '~') {
|
||||
return _homePath() ?? path;
|
||||
}
|
||||
if (path.startsWith('~/') || path.startsWith(r'~\')) {
|
||||
final home = _homePath();
|
||||
if (home == null) {
|
||||
return path;
|
||||
}
|
||||
return _joinPath(home, path.substring(2));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Joins [base] and [child] with the platform path separator.
|
||||
String _joinPath(String base, String child) {
|
||||
if (base.endsWith('/') || base.endsWith(r'\')) {
|
||||
return '$base$child';
|
||||
}
|
||||
return '$base${Platform.pathSeparator}$child';
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import 'app/focus_flow_app.dart';
|
||||
import 'app/persistent_scheduler_composition.dart';
|
||||
import 'app/runtime/focus_flow_file_logger.dart';
|
||||
import 'app/runtime/focus_flow_runtime_config.dart';
|
||||
|
||||
export 'app/demo_scheduler_composition.dart';
|
||||
export 'app/focus_flow_app.dart';
|
||||
|
|
@ -16,10 +18,25 @@ export 'app/persistent_scheduler_composition.dart';
|
|||
/// Starts the FocusFlow app with the persistent SQLite scheduler composition.
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
var logger = FocusFlowFileLogger.disabled();
|
||||
try {
|
||||
final composition = await PersistentSchedulerComposition.open();
|
||||
final config = await FocusFlowRuntimeConfig.load();
|
||||
logger = await FocusFlowFileLogger.open(config);
|
||||
await logger.info('FocusFlow startup started.');
|
||||
await logger.debug(
|
||||
'Runtime config loaded. fileLogging=${logger.isEnabled}',
|
||||
);
|
||||
final composition = await PersistentSchedulerComposition.open(
|
||||
logger: logger,
|
||||
);
|
||||
await logger.info('FocusFlow startup completed.');
|
||||
runApp(FocusFlowApp(composition: composition));
|
||||
} on Object catch (error) {
|
||||
} on Object catch (error, stackTrace) {
|
||||
await logger.error(
|
||||
'FocusFlow startup failed.',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
runApp(_StartupFailureApp(message: error.toString()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
apps/focus_flow_flutter/test/app/runtime_config_test.dart
Normal file
111
apps/focus_flow_flutter/test/app/runtime_config_test.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests FocusFlow runtime config and file logging behavior.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_flow_flutter/app/runtime/focus_flow_file_logger.dart';
|
||||
import 'package:focus_flow_flutter/app/runtime/focus_flow_runtime_config.dart';
|
||||
|
||||
/// Runs runtime config tests.
|
||||
void main() {
|
||||
group('FocusFlowRuntimeConfig', () {
|
||||
test('missing config file leaves optional settings unset', () async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'focus-flow-config-test-',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
|
||||
final config = await FocusFlowRuntimeConfig.load(
|
||||
path: '${directory.path}${Platform.pathSeparator}missing.json',
|
||||
);
|
||||
|
||||
expect(config.logLevel, isNull);
|
||||
expect(config.logFileLocation, isNull);
|
||||
expect(config.enablesFileLogging, isFalse);
|
||||
});
|
||||
|
||||
test('loads supported settings from a config file', () async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'focus-flow-config-file-test-',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final configFile = File(
|
||||
'${directory.path}${Platform.pathSeparator}config.json',
|
||||
);
|
||||
await configFile.writeAsString(
|
||||
jsonEncode({'LogLevel': 'trace', 'LogfileLocation': directory.path}),
|
||||
);
|
||||
|
||||
final config = await FocusFlowRuntimeConfig.load(path: configFile.path);
|
||||
|
||||
expect(config.logLevel, FocusFlowLogLevel.trace);
|
||||
expect(config.logFileLocation, directory.path);
|
||||
expect(config.enablesFileLogging, isTrue);
|
||||
});
|
||||
|
||||
test('loads supported logging settings from JSON', () {
|
||||
final config = FocusFlowRuntimeConfig.fromJson({
|
||||
'LogLevel': 'debug',
|
||||
'LogfileLocation': '/tmp/focus-flow-logs',
|
||||
});
|
||||
|
||||
expect(config.logLevel, FocusFlowLogLevel.debug);
|
||||
expect(config.logFileLocation, '/tmp/focus-flow-logs');
|
||||
expect(config.enablesFileLogging, isTrue);
|
||||
});
|
||||
|
||||
test('ignores missing and unsupported optional settings', () {
|
||||
final config = FocusFlowRuntimeConfig.fromJson({
|
||||
'LogLevel': 'verbose',
|
||||
'LogfileLocation': ' ',
|
||||
});
|
||||
|
||||
expect(config.logLevel, isNull);
|
||||
expect(config.logFileLocation, isNull);
|
||||
expect(config.enablesFileLogging, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('FocusFlowFileLogger', () {
|
||||
test('does nothing unless both logging settings are set', () async {
|
||||
final logger = await FocusFlowFileLogger.open(
|
||||
const FocusFlowRuntimeConfig(logLevel: FocusFlowLogLevel.debug),
|
||||
);
|
||||
|
||||
expect(logger.isEnabled, isFalse);
|
||||
await logger.info('ignored');
|
||||
});
|
||||
|
||||
test('writes messages at or above the configured level', () async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'focus-flow-log-test-',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final logger = await FocusFlowFileLogger.open(
|
||||
FocusFlowRuntimeConfig(
|
||||
logLevel: FocusFlowLogLevel.warn,
|
||||
logFileLocation: directory.path,
|
||||
),
|
||||
now: () => DateTime.utc(2026, 7, 3, 12),
|
||||
);
|
||||
|
||||
await logger.info('not written');
|
||||
await logger.warn('written warning');
|
||||
await logger.error('written error', error: 'boom');
|
||||
|
||||
final logFile = File(
|
||||
'${directory.path}${Platform.pathSeparator}focus_flow.log',
|
||||
);
|
||||
final contents = await logFile.readAsString();
|
||||
expect(contents, isNot(contains('not written')));
|
||||
expect(contents, contains('2026-07-03T12:00:00.000Z WARN'));
|
||||
expect(contents, contains('written warning'));
|
||||
expect(contents, contains('ERROR written error | error=boom'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -77,7 +77,8 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
|
|||
),
|
||||
if (relativePath != 'test/forbidden_imports_test.dart' &&
|
||||
relativePath != 'test/persistent_composition_test.dart' &&
|
||||
!_isPersistentComposition(relativePath))
|
||||
relativePath != 'test/app/runtime_config_test.dart' &&
|
||||
!_isApprovedRuntimeBoundary(relativePath))
|
||||
const _ForbiddenImport("dart:io", 'dart:io'),
|
||||
];
|
||||
}
|
||||
|
|
@ -87,6 +88,12 @@ bool _isPersistentComposition(String relativePath) {
|
|||
return relativePath == 'lib/app/persistent_scheduler_composition.dart';
|
||||
}
|
||||
|
||||
/// Whether [relativePath] is approved to touch local runtime files directly.
|
||||
bool _isApprovedRuntimeBoundary(String relativePath) {
|
||||
return _isPersistentComposition(relativePath) ||
|
||||
relativePath.startsWith('lib/app/runtime/');
|
||||
}
|
||||
|
||||
/// Private implementation type for `_ForbiddenImport` in this library.
|
||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||
class _ForbiddenImport {
|
||||
|
|
|
|||
Loading…
Reference in a new issue