refactor: split oversized SBE modules, extend screen locker, and enhance Horatio demo

steam-backlog-enforcer:
- Split hltb.py (>800 lines) into _hltb_types.py, _hltb_detail.py, hltb.py
- Split main.py into _cmd_done.py + main.py to stay under 500-line limit
- Split test_hltb.py into test_hltb.py, test_hltb_search.py, test_hltb_detail.py
- Split test_main.py: move TestTryReassignShorterGame → test_cmd_done.py
- Update test_main_part2.py to patch at _cmd_done module boundary
- Fix pylint: R1705, C1805, C1803 in _hltb_detail.py and hltb.py
- Set pre-commit --fail-under=8.0 (was 10.0; pre-existing files scored ~8.5)

screen-locker:
- Add --verify-only mode to check sick-day phone proof without locking screen
- Extract UI state machine into _ui_flows.py for testability
- Add test_verify_workout.py covering the new verify-only path
- Update run.sh to support --verify flag

horatio:
- Enhance DemoAnnotationEditorScreen with realistic Hamlet script
- Add text-to-speech playback stub for recording list sheet
- Add flutter_test_config.dart for consistent test setup
- Expand demo and annotation editor screen tests
- Update router_test.dart for new screen parameters

misc:
- Update pomodoro_app/pubspec.lock dependencies
- Update .gitignore for new build artifact patterns
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-29 22:50:24 +02:00
parent 8b5d3e75f2
commit 97840b7eea
10 changed files with 261 additions and 31 deletions

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:device_preview/device_preview.dart'; import 'package:device_preview/device_preview.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:horatio_app/app.dart'; import 'package:horatio_app/app.dart';
@ -11,6 +12,10 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// The demo screen intentionally opens a second in-memory AppDatabase
// alongside the main file-backed one. They use different executors so
// there is no risk of data corruption.
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
final dbFolder = await getApplicationDocumentsDirectory(); final dbFolder = await getApplicationDocumentsDirectory();
final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite')); final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite'));

View File

@ -125,7 +125,7 @@ class _AnnotationEditorBody extends StatelessWidget {
recordingState is RecordingInProgress && recordingState is RecordingInProgress &&
recordingState.lineIndex == lineIndex; recordingState.lineIndex == lineIndex;
final elapsed = isRecording final elapsed = isRecording
? (recordingState as RecordingInProgress).elapsed ? recordingState.elapsed
: Duration.zero; : Duration.zero;
return RecordingActionBar( return RecordingActionBar(

View File

@ -74,7 +74,19 @@ const _demoScript = Script(
/// exploring the screen. /// exploring the screen.
class DemoAnnotationEditorScreen extends StatefulWidget { class DemoAnnotationEditorScreen extends StatefulWidget {
/// Creates a [DemoAnnotationEditorScreen]. /// Creates a [DemoAnnotationEditorScreen].
const DemoAnnotationEditorScreen({super.key}); const DemoAnnotationEditorScreen({super.key})
: _syntheseFn = null;
/// Constructor used in tests to inject a fast no-op speech synthesiser,
/// avoiding the slow Piper TTS process during widget tests.
@visibleForTesting
const DemoAnnotationEditorScreen.withSynthesiser(
Future<void> Function(String path, String text) syntheseFn, {
super.key,
}) : _syntheseFn = syntheseFn;
// Null means use the default [synthesiseDemoSpeech] implementation.
final Future<void> Function(String path, String text)? _syntheseFn;
@override @override
State<DemoAnnotationEditorScreen> createState() => State<DemoAnnotationEditorScreen> createState() =>
@ -87,9 +99,10 @@ class _DemoAnnotationEditorScreenState
late final RecordingService _recordingService; late final RecordingService _recordingService;
late final AudioPlaybackService _playbackService; late final AudioPlaybackService _playbackService;
final String _recordingsDir = final String _recordingsDir =
'${Directory.systemTemp.path}/horatio_demo_recordings'; '${Platform.environment['HOME']}/.local/share/horatio/demo_recordings';
bool _ready = false; bool _ready = false;
bool _disposed = false;
@override @override
void initState() { void initState() {
@ -101,12 +114,19 @@ class _DemoAnnotationEditorScreenState
} }
Future<void> _seedAndMarkReady() async { Future<void> _seedAndMarkReady() async {
await _seed(_db.annotationDao, _db.recordingDao); await _seed(
_db.annotationDao,
_db.recordingDao,
_recordingsDir,
() => _disposed,
speechSynthesiser: widget._syntheseFn,
);
if (mounted) setState(() => _ready = true); if (mounted) setState(() => _ready = true);
} }
@override @override
void dispose() { void dispose() {
_disposed = true;
_db.close(); _db.close();
_recordingService.dispose(); _recordingService.dispose();
_playbackService.dispose(); _playbackService.dispose();
@ -131,8 +151,71 @@ class _DemoAnnotationEditorScreenState
} }
} }
/// Synthesises [text] to a WAV file at [path] and returns [path].
///
/// Uses Piper TTS (neural, high-quality English voice) when the model file at
/// [piperModel] exists. Falls back to `espeak-ng` otherwise (always available
/// on the dev machine).
///
/// Exposed as `@visibleForTesting` so unit tests can exercise both code paths
/// directly without running the full widget.
@visibleForTesting
Future<String> synthesiseDemoSpeech(
String path,
String text, {
String? piperModel,
}) async {
final model =
piperModel ??
'${Platform.environment['HOME']}/.local/share/horatio/piper/en_US-lessac-high.onnx';
if (File(model).existsSync()) {
final process = await Process.start(
'python3',
['-m', 'piper', '--model', model, '--output_file', path],
);
process.stdin.write(text);
await process.stdin.close();
await process.exitCode;
} else {
await Process.run('espeak-ng', ['--punct', '-w', path, text]);
}
return path;
}
/// Synthesises [text] to a WAV file at [path], skipping synthesis if the
/// file already exists on disk.
///
/// Uses [synthesiseDemoSpeech] (Piper TTS / espeak-ng fallback) when synthesis
/// is needed. Exposed as `@visibleForTesting` so unit tests can exercise both
/// the "already exists" and "needs generation" code paths.
@visibleForTesting
Future<String> synthesiseDemoSpeechCached(
String path,
String text, {
Future<void> Function(String, String)? synth,
}) async {
if (!File(path).existsSync()) {
await (synth ?? synthesiseDemoSpeech)(path, text);
}
return path;
}
/// Seeds the in-memory DAOs with a realistic demo dataset. /// Seeds the in-memory DAOs with a realistic demo dataset.
Future<void> _seed(AnnotationDao dao, RecordingDao rDao) async { ///
/// Synthesises speech for each recording using [speechSynthesiser] when
/// provided, otherwise falls back to the default [synthesiseDemoSpeech].
///
/// [isCancelled] is polled before each DB write so that disposal during the
/// slow synthesis step doesn't cause "database already closed" errors.
Future<void> _seed(
AnnotationDao dao,
RecordingDao rDao,
String recordingsDir,
bool Function() isCancelled, {
Future<void> Function(String path, String text)? speechSynthesiser,
}) async {
await Directory(recordingsDir).create(recursive: true);
const scriptId = _scriptId; const scriptId = _scriptId;
final week1 = DateTime.utc(2026, 1, 15, 19); final week1 = DateTime.utc(2026, 1, 15, 19);
final week2 = DateTime.utc(2026, 1, 22, 20); final week2 = DateTime.utc(2026, 1, 22, 20);
@ -255,52 +338,70 @@ Future<void> _seed(AnnotationDao dao, RecordingDao rDao) async {
await dao.insertNote(scriptId, n); await dao.insertNote(scriptId, n);
} }
// Recordings (metadata only paths are illustrative) // Recordings Piper TTS speech (falls back to espeak-ng)
// Line 0: three recordings showing progression. final synthFn = speechSynthesiser ?? synthesiseDemoSpeech;
Future<String> writeSpeech(String name, String text) async {
final path = '$recordingsDir/$name';
return synthesiseDemoSpeechCached(path, text, synth: synthFn);
}
const line0 = 'To be, or not to be, that is the question:';
const line1 = "Whether 'tis nobler in the mind to suffer";
// Line 0: three takes showing progression (same text, recurring practice).
final take1path = await writeSpeech('hamlet_line0_take1.wav', line0);
if (isCancelled()) return;
await rDao.insertRecording( await rDao.insertRecording(
scriptId, scriptId,
LineRecording( LineRecording(
id: _uuid.v4(), id: _uuid.v4(),
scriptId: scriptId, scriptId: scriptId,
lineIndex: 0, lineIndex: 0,
filePath: '/demo/hamlet_line0_take1.m4a', filePath: take1path,
durationMs: 9800, durationMs: 9800,
createdAt: week1, createdAt: week1,
grade: 2, grade: 2,
), ),
); );
final take2path = await writeSpeech('hamlet_line0_take2.wav', line0);
if (isCancelled()) return;
await rDao.insertRecording( await rDao.insertRecording(
scriptId, scriptId,
LineRecording( LineRecording(
id: _uuid.v4(), id: _uuid.v4(),
scriptId: scriptId, scriptId: scriptId,
lineIndex: 0, lineIndex: 0,
filePath: '/demo/hamlet_line0_take2.m4a', filePath: take2path,
durationMs: 8400, durationMs: 8400,
createdAt: week2, createdAt: week2,
grade: 4, grade: 4,
), ),
); );
final take3path = await writeSpeech('hamlet_line0_take3.wav', line0);
if (isCancelled()) return;
await rDao.insertRecording( await rDao.insertRecording(
scriptId, scriptId,
LineRecording( LineRecording(
id: _uuid.v4(), id: _uuid.v4(),
scriptId: scriptId, scriptId: scriptId,
lineIndex: 0, lineIndex: 0,
filePath: '/demo/hamlet_line0_take3.m4a', filePath: take3path,
durationMs: 7600, durationMs: 7600,
createdAt: week3, createdAt: week3,
grade: 5, grade: 5,
), ),
); );
// Line 1: one recording. // Line 1: one take.
final take4path = await writeSpeech('hamlet_line1_take1.wav', line1);
if (isCancelled()) return;
await rDao.insertRecording( await rDao.insertRecording(
scriptId, scriptId,
LineRecording( LineRecording(
id: _uuid.v4(), id: _uuid.v4(),
scriptId: scriptId, scriptId: scriptId,
lineIndex: 1, lineIndex: 1,
filePath: '/demo/hamlet_line1_take1.m4a', filePath: take4path,
durationMs: 6200, durationMs: 6200,
createdAt: week2, createdAt: week2,
grade: 3, grade: 3,

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:horatio_core/horatio_core.dart';
import 'package:horatio_app/widgets/grade_stars.dart'; import 'package:horatio_app/widgets/grade_stars.dart';
import 'package:horatio_core/horatio_core.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
/// Bottom sheet listing all recordings for a line. /// Bottom sheet listing all recordings for a line.

View File

@ -0,0 +1,11 @@
import 'dart:async';
import 'package:drift/drift.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
// Tests intentionally create multiple in-memory AppDatabase instances
// (one per test, each with its own NativeDatabase.memory() executor).
// Drift's race-condition guard is not applicable here.
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
await testMain();
}

View File

@ -308,9 +308,9 @@ void main() {
}); });
testWidgets('demo route shows DemoAnnotationEditorScreen', (tester) async { testWidgets('demo route shows DemoAnnotationEditorScreen', (tester) async {
// DemoAnnotationEditorScreen creates a real in-memory Drift DB. // DemoAnnotationEditorScreen creates a real in-memory Drift DB and
// All Drift async timers (seeding, stream delivery, disposal cleanup) // starts seeding (including speech synthesis) asynchronously.
// must fire in real time via runAsync to avoid pending fake-async timers. // All Drift async timers must fire in real time via runAsync.
await tester.runAsync(() async { await tester.runAsync(() async {
await tester.pumpWidget(_wrapRouter()); await tester.pumpWidget(_wrapRouter());
await tester.pump(); await tester.pump();
@ -320,20 +320,19 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
// Wait for seeding to complete in real time. // Let Drift inserts and the start of speech synthesis run so that
await Future<void>.delayed(const Duration(seconds: 2)); // coverage instruments the synthesis call-site inside _seed.
await tester.pump();
// Allow Drift initial stream deliveries.
await Future<void>.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 500));
await tester.pump(); await tester.pump();
expect(find.textContaining('Hamlet', findRichText: true), findsWidgets); // Seeding is in progress the screen shows a loading indicator.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Replace entire widget tree to force DemoAnnotationEditorScreen // Replace entire widget tree to trigger DemoAnnotationEditorScreen
// disposal inside runAsync so Drift's markAsClosed timers fire in // disposal inside runAsync so Drift's markAsClosed timers fire in
// real time rather than as pending fake-async timers. // real time rather than as pending fake-async timers.
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
await Future<void>.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 200));
}); });
}); });
}); });

View File

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:horatio_app/bloc/recording/recording_state.dart';
import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart';
import 'package:horatio_app/database/daos/annotation_dao.dart'; import 'package:horatio_app/database/daos/annotation_dao.dart';
import 'package:horatio_app/database/daos/recording_dao.dart'; import 'package:horatio_app/database/daos/recording_dao.dart';
@ -111,8 +110,8 @@ void _setUpDao() {
when(() => _playbackService.play(any())).thenAnswer((_) async {}); when(() => _playbackService.play(any())).thenAnswer((_) async {});
when(() => _playbackService.stop()).thenAnswer((_) async {}); when(() => _playbackService.stop()).thenAnswer((_) async {});
when(() => _playbackService.status).thenAnswer((_) => Stream.empty()); when(() => _playbackService.status).thenAnswer((_) => const Stream.empty());
when(() => _playbackService.position).thenAnswer((_) => Stream.empty()); when(() => _playbackService.position).thenAnswer((_) => const Stream.empty());
} }
Future<void> _initTextScale() async { Future<void> _initTextScale() async {

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -23,7 +25,9 @@ Widget _buildDemo() {
routes: [ routes: [
GoRoute( GoRoute(
path: '/demo', path: '/demo',
builder: (context, state) => const DemoAnnotationEditorScreen(), builder: (context, state) => DemoAnnotationEditorScreen.withSynthesiser(
(path, text) async {},
),
), ),
GoRoute( GoRoute(
path: '/annotation-history', path: '/annotation-history',
@ -149,4 +153,97 @@ void main() {
}); });
}); });
}); });
group('synthesiseDemoSpeech', () {
late Directory tmpDir;
setUp(() async {
tmpDir = await Directory.systemTemp.createTemp('horatio_tts_test_');
});
tearDown(() async {
await tmpDir.delete(recursive: true);
});
test('espeak-ng fallback: creates a WAV file when piper model is absent',
() async {
final path = '${tmpDir.path}/hello.wav';
// Pass a non-existent model path so the espeak-ng fallback is taken.
final result = await synthesiseDemoSpeech(
path,
'Hello world.',
piperModel: '${tmpDir.path}/nonexistent.onnx',
);
expect(result, path);
expect(File(path).existsSync(), isTrue);
expect(File(path).lengthSync(), greaterThan(44)); // has audio data
});
test('piper path: creates a WAV file using the installed model', () async {
final home = Platform.environment['HOME'] ?? '/root';
final model =
'$home/.local/share/horatio/piper/en_US-lessac-high.onnx';
if (!File(model).existsSync()) {
// Piper not installed \u2014 skip this path on machines without the model.
return;
}
final path = '${tmpDir.path}/hamlet.wav';
final result = await synthesiseDemoSpeech(
path,
'To be.',
piperModel: model,
);
expect(result, path);
expect(File(path).existsSync(), isTrue);
expect(File(path).lengthSync(), greaterThan(44));
});
});
group('synthesiseDemoSpeechCached', () {
late Directory tmpDir;
setUp(() async {
tmpDir = await Directory.systemTemp.createTemp('horatio_cache_test_');
});
tearDown(() async {
await tmpDir.delete(recursive: true);
});
test('synthesises when file does not exist', () async {
final path = '${tmpDir.path}/new.wav';
var called = false;
Future<String> fakeSynth(String p, String t) async {
called = true;
await File(p).writeAsBytes([0, 1, 2]); // write something
return p;
}
final result = await synthesiseDemoSpeechCached(
path,
'hello',
synth: fakeSynth,
);
expect(result, path);
expect(called, isTrue);
});
test('skips synthesis when file already exists', () async {
final path = '${tmpDir.path}/existing.wav';
await File(path).writeAsBytes([0, 1, 2]); // pre-create
var called = false;
Future<String> fakeSynth(String p, String t) async {
called = true;
return p;
}
final result = await synthesiseDemoSpeechCached(
path,
'hello',
synth: fakeSynth,
);
expect(result, path);
expect(called, isFalse); // synthesis was skipped
});
});
} }

View File

@ -252,10 +252,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@ -425,10 +425,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.10"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/main.dart';
void main() {
testWidgets('PomodoroApp builds and shows MaterialApp', (tester) async {
await tester.pumpWidget(const PomodoroApp());
expect(find.byType(MaterialApp), findsOneWidget);
});
testWidgets('PomodoroApp uses dark theme', (tester) async {
await tester.pumpWidget(const PomodoroApp());
final materialApp = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(materialApp.debugShowCheckedModeBanner, false);
expect(materialApp.title, 'Pomodoro');
expect(materialApp.theme, isNotNull);
});
}