diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9498f36..64a2cac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -152,7 +152,7 @@ repos: - id: pylint args: - --rcfile=pyproject.toml - - --fail-under=5.0 + - --fail-under=10.0 - --jobs=0 additional_dependencies: - python-chess diff --git a/horatio/horatio_app/lib/router.dart b/horatio/horatio_app/lib/router.dart index b9777c1..0104406 100644 --- a/horatio/horatio_app/lib/router.dart +++ b/horatio/horatio_app/lib/router.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:horatio_app/screens/annotation_editor_screen.dart'; import 'package:horatio_app/screens/annotation_history_screen.dart'; +import 'package:horatio_app/screens/demo_annotation_editor_screen.dart'; import 'package:horatio_app/screens/home_screen.dart'; import 'package:horatio_app/screens/import_screen.dart'; import 'package:horatio_app/screens/rehearsal_screen.dart'; @@ -35,6 +36,9 @@ abstract final class RoutePaths { /// Annotation history. static const String annotationHistory = '/annotation-history'; + + /// Interactive demo — ephemeral in-memory Hamlet excerpt. + static const String demo = '/demo'; } /// Application router configuration. @@ -120,11 +124,13 @@ final GoRouter appRouter = GoRouter( return const SizedBox.shrink(); }, ), + GoRoute( + path: RoutePaths.demo, + builder: (context, state) => const DemoAnnotationEditorScreen(), + ), ], errorBuilder: (context, state) => Scaffold( appBar: AppBar(title: const Text('Not Found')), - body: Center( - child: Text('Page not found: ${state.uri}'), - ), + body: Center(child: Text('Page not found: ${state.uri}')), ), ); diff --git a/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart b/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart new file mode 100644 index 0000000..a7ff96c --- /dev/null +++ b/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart @@ -0,0 +1,321 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/screens/annotation_editor_screen.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); +const _scriptId = 'demo-hamlet-soliloquy'; +const _hamlet = Role(name: 'Hamlet'); + +/// Demo script — Hamlet's soliloquy (6 lines). +const _demoScript = Script( + id: _scriptId, + title: 'Hamlet — To be, or not to be (demo)', + roles: [_hamlet], + scenes: [ + Scene( + title: 'Act III, Scene I', + lines: [ + ScriptLine( + text: 'To be, or not to be, that is the question:', + role: _hamlet, + sceneIndex: 0, + lineIndex: 0, + ), + ScriptLine( + text: "Whether 'tis nobler in the mind to suffer", + role: _hamlet, + sceneIndex: 0, + lineIndex: 1, + ), + ScriptLine( + text: 'The slings and arrows of outrageous fortune,', + role: _hamlet, + sceneIndex: 0, + lineIndex: 2, + ), + ScriptLine( + text: 'Or to take arms against a sea of troubles', + role: _hamlet, + sceneIndex: 0, + lineIndex: 3, + ), + ScriptLine( + text: 'And by opposing end them.', + role: _hamlet, + sceneIndex: 0, + lineIndex: 4, + ), + ScriptLine( + text: 'To die: to sleep; no more;', + role: _hamlet, + sceneIndex: 0, + lineIndex: 5, + ), + ], + ), + ], +); + +/// Wraps [AnnotationEditorScreen] with a fully in-memory database seeded with +/// realistic demo annotations, notes, and recordings. +/// +/// All data lives only in RAM — nothing is written to disk or the real DB. +/// The demo is fully interactive: users can add/edit marks and notes while +/// exploring the screen. +class DemoAnnotationEditorScreen extends StatefulWidget { + /// Creates a [DemoAnnotationEditorScreen]. + const DemoAnnotationEditorScreen({super.key}); + + @override + State createState() => + _DemoAnnotationEditorScreenState(); +} + +class _DemoAnnotationEditorScreenState + extends State { + late final AppDatabase _db; + late final RecordingService _recordingService; + late final AudioPlaybackService _playbackService; + final String _recordingsDir = + '${Directory.systemTemp.path}/horatio_demo_recordings'; + + bool _ready = false; + + @override + void initState() { + super.initState(); + _db = AppDatabase(NativeDatabase.memory()); + _recordingService = RecordingService(); + _playbackService = AudioPlaybackService(); + _seedAndMarkReady(); + } + + Future _seedAndMarkReady() async { + await _seed(_db.annotationDao, _db.recordingDao); + if (mounted) setState(() => _ready = true); + } + + @override + void dispose() { + _db.close(); + _recordingService.dispose(); + _playbackService.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: _db.annotationDao), + RepositoryProvider.value(value: _db.recordingDao), + RepositoryProvider.value(value: _recordingService), + RepositoryProvider.value(value: _playbackService), + RepositoryProvider.value(value: _recordingsDir), + ], + child: const AnnotationEditorScreen(script: _demoScript), + ); + } +} + +/// Seeds the in-memory DAOs with a realistic demo dataset. +Future _seed(AnnotationDao dao, RecordingDao rDao) async { + const scriptId = _scriptId; + final week1 = DateTime.utc(2026, 1, 15, 19); + final week2 = DateTime.utc(2026, 1, 22, 20); + final week3 = DateTime.utc(2026, 2, 1, 18); + final now = DateTime.utc(2026, 3, 15, 21); + + // ── Text marks ────────────────────────────────────────────────────────── + // Collect marks as we insert them so the snapshot doesn't need stream reads. + final marks = []; + + TextMark newMark({ + required int lineIndex, + required int startOffset, + required int endOffset, + required MarkType type, + required DateTime createdAt, + }) => TextMark( + id: _uuid.v4(), + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + type: type, + createdAt: createdAt, + ); + + // Line 0: stress "To be" + slow down the question; + // Line 2: emphasis on "slings and arrows"; + // Line 4: breath before ending. + marks + ..add( + newMark( + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: week1, + ), + ) + ..add( + newMark( + lineIndex: 0, + startOffset: 16, + endOffset: 43, + type: MarkType.slowDown, + createdAt: week2, + ), + ) + ..add( + newMark( + lineIndex: 2, + startOffset: 4, + endOffset: 20, + type: MarkType.emphasis, + createdAt: week1, + ), + ) + ..add( + newMark( + lineIndex: 4, + startOffset: 0, + endOffset: 13, + type: MarkType.breath, + createdAt: week3, + ), + ); + for (final m in marks) { + await dao.insertMark(scriptId, m); + } + + // ── Line notes ────────────────────────────────────────────────────────── + final notes = []; + + LineNote newNote({ + required int lineIndex, + required NoteCategory category, + required String text, + required DateTime createdAt, + }) => LineNote( + id: _uuid.v4(), + lineIndex: lineIndex, + category: category, + text: text, + createdAt: createdAt, + ); + + notes + ..add( + newNote( + lineIndex: 1, + category: NoteCategory.delivery, + text: 'Breathe slowly before this line — let the weight land', + createdAt: week1, + ), + ) + ..add( + newNote( + lineIndex: 3, + category: NoteCategory.blocking, + text: 'Step downstage, half-turn to audience on "Or to take arms"', + createdAt: week2, + ), + ) + ..add( + newNote( + lineIndex: 3, + category: NoteCategory.intention, + text: 'He has already decided — resignation, not a genuine question', + createdAt: week3, + ), + ) + ..add( + newNote( + lineIndex: 0, + category: NoteCategory.subtext, + text: "Staring at nothing. The audience exists; he doesn't see them", + createdAt: now, + ), + ); + for (final n in notes) { + await dao.insertNote(scriptId, n); + } + + // ── Recordings (metadata only — paths are illustrative) ───────────────── + // Line 0: three recordings showing progression. + await rDao.insertRecording( + scriptId, + LineRecording( + id: _uuid.v4(), + scriptId: scriptId, + lineIndex: 0, + filePath: '/demo/hamlet_line0_take1.m4a', + durationMs: 9800, + createdAt: week1, + grade: 2, + ), + ); + await rDao.insertRecording( + scriptId, + LineRecording( + id: _uuid.v4(), + scriptId: scriptId, + lineIndex: 0, + filePath: '/demo/hamlet_line0_take2.m4a', + durationMs: 8400, + createdAt: week2, + grade: 4, + ), + ); + await rDao.insertRecording( + scriptId, + LineRecording( + id: _uuid.v4(), + scriptId: scriptId, + lineIndex: 0, + filePath: '/demo/hamlet_line0_take3.m4a', + durationMs: 7600, + createdAt: week3, + grade: 5, + ), + ); + // Line 1: one recording. + await rDao.insertRecording( + scriptId, + LineRecording( + id: _uuid.v4(), + scriptId: scriptId, + lineIndex: 1, + filePath: '/demo/hamlet_line1_take1.m4a', + durationMs: 6200, + createdAt: week2, + grade: 3, + ), + ); + + // ── Annotation snapshot (history entry from week 1) ────────────────────── + // Use the already-collected marks/notes — no stream reads needed. + await dao.insertSnapshot( + AnnotationSnapshot( + id: _uuid.v4(), + scriptId: scriptId, + timestamp: week1, + marks: marks, + notes: notes, + ), + ); +} diff --git a/horatio/horatio_app/lib/screens/home_screen.dart b/horatio/horatio_app/lib/screens/home_screen.dart index 39e1b95..ae80f05 100644 --- a/horatio/horatio_app/lib/screens/home_screen.dart +++ b/horatio/horatio_app/lib/screens/home_screen.dart @@ -52,135 +52,116 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Horatio'), - actions: [ - IconButton( - icon: const Icon(Icons.text_fields), - tooltip: 'Text Size', - onPressed: () => showModalBottomSheet( - context: context, - builder: (_) => BlocProvider.value( - value: context.read(), - child: const TextScaleSettingsSheet(), - ), - ), + appBar: AppBar( + title: const Text('Horatio'), + actions: [ + IconButton( + icon: const Icon(Icons.text_fields), + tooltip: 'Text Size', + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const TextScaleSettingsSheet(), ), - ], - ), - body: DropTarget( - onDragDone: _handleDrop, - onDragEntered: (_) => setState(() => _isDragging = true), - onDragExited: (_) => setState(() => _isDragging = false), - child: BlocBuilder( - builder: (context, state) => switch (state) { - ScriptImportLoading() => const Center( - child: CircularProgressIndicator(), - ), - ScriptImportError(:final message) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.error_outline, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(message, textAlign: TextAlign.center), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => context - .read() - .loadScripts(), - child: const Text('Retry'), - ), - ], - ), - ), - ScriptImportLoaded(:final scripts) - when scripts.isEmpty => - _EmptyLibrary(isDragging: _isDragging), - ScriptImportLoaded(:final scripts) => Stack( - children: [ - ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: scripts.length, - itemBuilder: (context, index) => ScriptCardWidget( - script: scripts[index], - onTap: () => context.push( - RoutePaths.roleSelection, - extra: scripts[index], - ), - onDelete: () => context - .read() - .removeScript(index), - ), - ), - if (_isDragging) - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.1), - border: Border.all( - color: - Theme.of(context).colorScheme.primary, - width: 3, - ), - borderRadius: BorderRadius.circular(16), - ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.file_download, - size: 64, - color: Theme.of(context) - .colorScheme - .primary, - ), - const SizedBox(height: 16), - Text( - 'Drop script file here', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith( - color: Theme.of(context) - .colorScheme - .primary, - ), - ), - const SizedBox(height: 8), - Text( - '.txt .docx .pdf', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ScriptImportInitial() => - _EmptyLibrary(isDragging: _isDragging), - }, ), ), - ); + ], + ), + body: DropTarget( + onDragDone: _handleDrop, + onDragEntered: (_) => setState(() => _isDragging = true), + onDragExited: (_) => setState(() => _isDragging = false), + child: BlocBuilder( + builder: (context, state) => switch (state) { + ScriptImportLoading() => const Center( + child: CircularProgressIndicator(), + ), + ScriptImportError(:final message) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => + context.read().loadScripts(), + child: const Text('Retry'), + ), + ], + ), + ), + ScriptImportLoaded(:final scripts) when scripts.isEmpty => + _EmptyLibrary(isDragging: _isDragging), + ScriptImportLoaded(:final scripts) => Stack( + children: [ + ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: scripts.length, + itemBuilder: (context, index) => ScriptCardWidget( + script: scripts[index], + onTap: () => context.push( + RoutePaths.roleSelection, + extra: scripts[index], + ), + onDelete: () => + context.read().removeScript(index), + ), + ), + if (_isDragging) + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.file_download, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Drop script file here', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 8), + Text( + '.txt .docx .pdf', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ScriptImportInitial() => _EmptyLibrary(isDragging: _isDragging), + }, + ), + ), + ); } /// Bundled public domain script metadata for the suggestion cards. @@ -245,8 +226,7 @@ class _EmptyLibrary extends StatelessWidget { children: [ const SizedBox(height: 8), GestureDetector( - onTap: () => - context.read().importFromFile(), + onTap: () => context.read().importFromFile(), child: MouseRegion( cursor: SystemMouseCursors.click, child: CustomPaint( @@ -272,23 +252,18 @@ class _EmptyLibrary extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - isDragging - ? Icons.file_download - : Icons.upload_file, + isDragging ? Icons.file_download : Icons.upload_file, size: 72, color: isDragging ? colorScheme.primary - : colorScheme.primary - .withValues(alpha: 0.6), + : colorScheme.primary.withValues(alpha: 0.6), ), const SizedBox(height: 20), Text( isDragging ? 'Drop to import' : 'Drop or click to import file', - style: Theme.of(context) - .textTheme - .headlineSmall + style: Theme.of(context).textTheme.headlineSmall ?.copyWith( color: isDragging ? colorScheme.primary @@ -299,12 +274,9 @@ class _EmptyLibrary extends StatelessWidget { const SizedBox(height: 8), Text( 'Supports .txt .docx .pdf', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -313,11 +285,20 @@ class _EmptyLibrary extends StatelessWidget { ), ), const SizedBox(height: 24), + OutlinedButton.icon( + icon: const Icon(Icons.play_circle_outline), + label: const Text('See a demo'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + onPressed: () => context.push(RoutePaths.demo), + ), + const SizedBox(height: 24), Text( 'or try a classic', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + color: colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 16), Text( @@ -332,9 +313,9 @@ class _EmptyLibrary extends StatelessWidget { title: Text(entry.title), subtitle: Text(entry.author), trailing: const Icon(Icons.download), - onTap: () => context - .read() - .importFromAsset(entry.assetPath), + onTap: () => context.read().importFromAsset( + entry.assetPath, + ), ), ), ), @@ -379,10 +360,7 @@ class _DashedBorderPainter extends CustomPainter { var distance = 0.0; while (distance < metric.length) { final end = (distance + dashWidth).clamp(0.0, metric.length); - dashedPath.addPath( - metric.extractPath(distance, end), - Offset.zero, - ); + dashedPath.addPath(metric.extractPath(distance, end), Offset.zero); distance += dashWidth + dashSpace; } } diff --git a/horatio/horatio_app/test/router_test.dart b/horatio/horatio_app/test/router_test.dart index c1497bf..a926c9f 100644 --- a/horatio/horatio_app/test/router_test.dart +++ b/horatio/horatio_app/test/router_test.dart @@ -41,20 +41,20 @@ Widget _wrapRouter() { () => mockRecordingDao.watchRecordingsForScript(any()), ).thenAnswer((_) => Stream.value([])); - when( - () => mockRecordingService.hasPermission(), - ).thenAnswer((_) async => true); + when(mockRecordingService.hasPermission).thenAnswer((_) async => true); when( () => mockRecordingService.startRecording(any()), ).thenAnswer((_) async {}); - when( - () => mockRecordingService.stopRecording(), - ).thenAnswer((_) async => null); + when(mockRecordingService.stopRecording).thenAnswer((_) async => null); when(() => mockPlaybackService.play(any())).thenAnswer((_) async {}); - when(() => mockPlaybackService.stop()).thenAnswer((_) async {}); - when(() => mockPlaybackService.status).thenAnswer((_) => Stream.empty()); - when(() => mockPlaybackService.position).thenAnswer((_) => Stream.empty()); + when(mockPlaybackService.stop).thenAnswer((_) async {}); + when( + () => mockPlaybackService.status, + ).thenAnswer((_) => const Stream.empty()); + when( + () => mockPlaybackService.position, + ).thenAnswer((_) => const Stream.empty()); return MultiRepositoryProvider( providers: [ @@ -306,5 +306,35 @@ void main() { // Redirected to home. expect(find.text('Horatio'), findsOneWidget); }); + + testWidgets('demo route shows DemoAnnotationEditorScreen', (tester) async { + // DemoAnnotationEditorScreen creates a real in-memory Drift DB. + // All Drift async timers (seeding, stream delivery, disposal cleanup) + // must fire in real time via runAsync to avoid pending fake-async timers. + await tester.runAsync(() async { + await tester.pumpWidget(_wrapRouter()); + await tester.pump(); + + appRouter.go(RoutePaths.demo); + // Process the navigation frame. + await tester.pump(); + await tester.pump(); + + // Wait for seeding to complete in real time. + await Future.delayed(const Duration(seconds: 2)); + await tester.pump(); + // Allow Drift initial stream deliveries. + await Future.delayed(const Duration(milliseconds: 500)); + await tester.pump(); + + expect(find.textContaining('Hamlet', findRichText: true), findsWidgets); + + // Replace entire widget tree to force DemoAnnotationEditorScreen + // disposal inside runAsync so Drift's markAsClosed timers fire in + // real time rather than as pending fake-async timers. + await tester.pumpWidget(const SizedBox.shrink()); + await Future.delayed(const Duration(milliseconds: 500)); + }); + }); }); } diff --git a/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart b/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart new file mode 100644 index 0000000..84c9976 --- /dev/null +++ b/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/screens/annotation_editor_screen.dart'; +import 'package:horatio_app/screens/demo_annotation_editor_screen.dart'; +import 'package:horatio_app/widgets/note_chip.dart'; +import 'package:horatio_app/widgets/recording_action_bar.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +late TextScaleCubit _textScaleCubit; + +Future _initTextScale() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + _textScaleCubit = TextScaleCubit(prefs: prefs); +} + +Widget _buildDemo() { + final router = GoRouter( + initialLocation: '/demo', + routes: [ + GoRoute( + path: '/demo', + builder: (context, state) => const DemoAnnotationEditorScreen(), + ), + GoRoute( + path: '/annotation-history', + builder: (context, state) => + const Scaffold(body: Text('History Screen')), + ), + ], + ); + return BlocProvider.value( + value: _textScaleCubit, + child: MaterialApp.router(routerConfig: router), + ); +} + +/// Runs a demo screen widget test entirely inside [tester.runAsync]. +/// +/// [DemoAnnotationEditorScreen] creates a real Drift in-memory database. +/// Drift schedules timers for initial stream data delivery and for cleanup on +/// disposal (via [StreamQueryStore.markAsClosed] when cubits close and cancel +/// stream subscriptions). Running the full lifecycle — pump, seed wait, cubit +/// init, assertions, and explicit teardown — inside [tester.runAsync] ensures +/// every timer fires in real time and is never left pending as a fake-async +/// timer when the test ends. +Future _runDemoTest( + WidgetTester tester, + Future Function() assertions, +) async { + await tester.runAsync(() async { + await tester.pumpWidget(_buildDemo()); + // Seeding completes in real time. + await Future.delayed(const Duration(seconds: 2)); + // Rebuild with _ready = true; creates AnnotationCubit + RecordingCubit + // which subscribe to Drift streams. + await tester.pump(); + // Allow Drift's initial stream deliveries to fire in real time. + await Future.delayed(const Duration(milliseconds: 500)); + // Settle cubit-driven rebuilds. + await tester.pump(); + + await assertions(); + + // Teardown inside runAsync so Drift's markAsClosed timers fire in real + // time rather than as pending fake-async timers. + await tester.pumpWidget(const SizedBox.shrink()); + await Future.delayed(const Duration(milliseconds: 300)); + }); +} + +void main() { + setUpAll(() async { + await _initTextScale(); + }); + + tearDownAll(() => _textScaleCubit.close()); + + group('DemoAnnotationEditorScreen', () { + testWidgets('shows loading indicator while seeding', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(_buildDemo()); + await tester.pump(); + // Immediately after the first frame, seeding is still in progress. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Let seeding finish before disposing so the in-flight DB inserts + // don't hit a closed database. + await Future.delayed(const Duration(seconds: 2)); + + await tester.pumpWidget(const SizedBox.shrink()); + await Future.delayed(const Duration(milliseconds: 300)); + }); + }); + + testWidgets('renders AnnotationEditorScreen after seeding', (tester) async { + await _runDemoTest(tester, () async { + expect(find.byType(AnnotationEditorScreen), findsOneWidget); + }); + }); + + testWidgets('shows Hamlet title in app bar', (tester) async { + await _runDemoTest(tester, () async { + expect(find.textContaining('Hamlet'), findsWidgets); + }); + }); + + testWidgets('shows demo script lines', (tester) async { + await _runDemoTest(tester, () async { + expect( + find.text( + 'To be, or not to be, that is the question:', + findRichText: true, + ), + findsOneWidget, + ); + }); + }); + + testWidgets('tapping a line shows RecordingActionBar', (tester) async { + await _runDemoTest(tester, () async { + await tester.tap( + find.text( + 'To be, or not to be, that is the question:', + findRichText: true, + ), + ); + await tester.pump(); + + expect(find.byType(RecordingActionBar), findsOneWidget); + }); + }); + + testWidgets('demo data shows note chips on seeded line', (tester) async { + await _runDemoTest(tester, () async { + // Line 3 has two seeded notes (blocking + intention). + await tester.tap( + find.text( + 'Or to take arms against a sea of troubles', + findRichText: true, + ), + ); + await tester.pump(); + + expect(find.byType(NoteChip), findsWidgets); + }); + }); + }); +} diff --git a/horatio/horatio_app/test/screens/home_screen_test.dart b/horatio/horatio_app/test/screens/home_screen_test.dart index 7cca377..fbc6ffd 100644 --- a/horatio/horatio_app/test/screens/home_screen_test.dart +++ b/horatio/horatio_app/test/screens/home_screen_test.dart @@ -25,14 +25,15 @@ Widget _wrap(ScriptImportCubit cubit) { final router = GoRouter( initialLocation: '/', routes: [ - GoRoute( - path: '/', - builder: (context, state) => const HomeScreen(), - ), + GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute( path: '/role-selection', builder: (context, state) => const Scaffold(), ), + GoRoute( + path: '/demo', + builder: (context, state) => const Scaffold(body: Text('Demo Screen')), + ), ], ); return MultiBlocProvider( @@ -42,9 +43,7 @@ Widget _wrap(ScriptImportCubit cubit) { ], child: MultiRepositoryProvider( providers: [ - RepositoryProvider( - create: (_) => ScriptRepository(), - ), + RepositoryProvider(create: (_) => ScriptRepository()), ], child: MaterialApp.router(routerConfig: router), ), @@ -62,10 +61,12 @@ void main() { when(() => cubit.loadScripts()).thenReturn(null); when(() => cubit.importFromFile()).thenAnswer((_) async {}); when(() => cubit.importFromAsset(any())).thenAnswer((_) async {}); - when(() => cubit.importFromBytes( - bytes: any(named: 'bytes'), - fileName: any(named: 'fileName'), - )).thenAnswer((_) async {}); + when( + () => cubit.importFromBytes( + bytes: any(named: 'bytes'), + fileName: any(named: 'fileName'), + ), + ).thenAnswer((_) async {}); }); tearDown(() => _textScaleCubit.close()); @@ -75,8 +76,9 @@ void main() { }); group('HomeScreen states', () { - testWidgets('shows loading indicator for ScriptImportLoading', - (tester) async { + testWidgets('shows loading indicator for ScriptImportLoading', ( + tester, + ) async { when(() => cubit.state).thenReturn(const ScriptImportLoading()); await tester.pumpWidget(_wrap(cubit)); @@ -86,8 +88,9 @@ void main() { }); testWidgets('shows error view for ScriptImportError', (tester) async { - when(() => cubit.state) - .thenReturn(const ScriptImportError(message: 'Disk full')); + when( + () => cubit.state, + ).thenReturn(const ScriptImportError(message: 'Disk full')); await tester.pumpWidget(_wrap(cubit)); await tester.pump(); @@ -98,8 +101,9 @@ void main() { }); testWidgets('Retry button calls loadScripts', (tester) async { - when(() => cubit.state) - .thenReturn(const ScriptImportError(message: 'Fail')); + when( + () => cubit.state, + ).thenReturn(const ScriptImportError(message: 'Fail')); await tester.pumpWidget(_wrap(cubit)); await tester.pump(); @@ -113,8 +117,9 @@ void main() { verify(() => cubit.loadScripts()).called(1); }); - testWidgets('shows script list for loaded state with scripts', - (tester) async { + testWidgets('shows script list for loaded state with scripts', ( + tester, + ) async { const role = Role(name: 'Hero'); const script = Script( id: 'home-my-play-id', @@ -133,8 +138,9 @@ void main() { ), ], ); - when(() => cubit.state) - .thenReturn(const ScriptImportLoaded(scripts: [script])); + when( + () => cubit.state, + ).thenReturn(const ScriptImportLoaded(scripts: [script])); when(() => cubit.removeScript(any())).thenReturn(null); await tester.pumpWidget(_wrap(cubit)); @@ -144,8 +150,9 @@ void main() { expect(find.text('Horatio'), findsOneWidget); // App bar. }); - testWidgets('delete button on script card calls removeScript', - (tester) async { + testWidgets('delete button on script card calls removeScript', ( + tester, + ) async { const role = Role(name: 'Hero'); const script = Script( id: 'home-play-id', @@ -154,18 +161,14 @@ void main() { scenes: [ Scene( lines: [ - ScriptLine( - text: 'Hi.', - role: role, - sceneIndex: 0, - lineIndex: 0, - ), + ScriptLine(text: 'Hi.', role: role, sceneIndex: 0, lineIndex: 0), ], ), ], ); - when(() => cubit.state) - .thenReturn(const ScriptImportLoaded(scripts: [script])); + when( + () => cubit.state, + ).thenReturn(const ScriptImportLoaded(scripts: [script])); when(() => cubit.removeScript(any())).thenReturn(null); await tester.pumpWidget(_wrap(cubit)); @@ -177,6 +180,30 @@ void main() { verify(() => cubit.removeScript(0)).called(1); }); + testWidgets('shows See a demo button in empty library state', ( + tester, + ) async { + when(() => cubit.state).thenReturn(const ScriptImportInitial()); + + await tester.pumpWidget(_wrap(cubit)); + await tester.pumpAndSettle(); + + expect(find.text('See a demo'), findsOneWidget); + expect(find.byIcon(Icons.play_circle_outline), findsOneWidget); + }); + + testWidgets('tapping See a demo navigates to /demo', (tester) async { + when(() => cubit.state).thenReturn(const ScriptImportInitial()); + + await tester.pumpWidget(_wrap(cubit)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('See a demo')); + await tester.pumpAndSettle(); + + expect(find.text('Demo Screen'), findsOneWidget); + }); + testWidgets('tapping import zone calls importFromFile', (tester) async { when(() => cubit.state).thenReturn(const ScriptImportInitial()); @@ -193,8 +220,9 @@ void main() { verify(() => cubit.importFromFile()).called(1); }); - testWidgets('tapping public domain script calls importFromAsset', - (tester) async { + testWidgets('tapping public domain script calls importFromAsset', ( + tester, + ) async { when(() => cubit.state).thenReturn(const ScriptImportInitial()); await tester.pumpWidget(_wrap(cubit)); @@ -214,8 +242,9 @@ void main() { DropTarget findDropTarget(WidgetTester tester) => tester.widget(find.byType(DropTarget)); - testWidgets('onDragEntered sets isDragging true in empty library', - (tester) async { + testWidgets('onDragEntered sets isDragging true in empty library', ( + tester, + ) async { when(() => cubit.state).thenReturn(const ScriptImportInitial()); await tester.pumpWidget(_wrap(cubit)); @@ -287,14 +316,17 @@ void main() { }); await tester.pump(); - verify(() => cubit.importFromBytes( - bytes: any(named: 'bytes'), - fileName: 'script.txt', - )).called(1); + verify( + () => cubit.importFromBytes( + bytes: any(named: 'bytes'), + fileName: 'script.txt', + ), + ).called(1); }); - testWidgets('onDragDone shows snackbar for unsupported file type', - (tester) async { + testWidgets('onDragDone shows snackbar for unsupported file type', ( + tester, + ) async { when(() => cubit.state).thenReturn(const ScriptImportInitial()); await tester.pumpWidget(_wrap(cubit)); @@ -319,8 +351,9 @@ void main() { expect(find.text('Unsupported file type: .xyz'), findsOneWidget); }); - testWidgets('drag overlay appears in loaded-with-scripts state', - (tester) async { + testWidgets('drag overlay appears in loaded-with-scripts state', ( + tester, + ) async { const role = Role(name: 'Hero'); const script = Script( id: 'home-drag-id', @@ -329,18 +362,14 @@ void main() { scenes: [ Scene( lines: [ - ScriptLine( - text: 'Hi.', - role: role, - sceneIndex: 0, - lineIndex: 0, - ), + ScriptLine(text: 'Hi.', role: role, sceneIndex: 0, lineIndex: 0), ], ), ], ); - when(() => cubit.state) - .thenReturn(const ScriptImportLoaded(scripts: [script])); + when( + () => cubit.state, + ).thenReturn(const ScriptImportLoaded(scripts: [script])); when(() => cubit.removeScript(any())).thenReturn(null); await tester.pumpWidget(_wrap(cubit)); @@ -359,8 +388,9 @@ void main() { expect(find.text('.txt .docx .pdf'), findsOneWidget); }); - testWidgets('tapping script card navigates to role-selection', - (tester) async { + testWidgets('tapping script card navigates to role-selection', ( + tester, + ) async { const role = Role(name: 'Hero'); const script = Script( id: 'home-nav-id', @@ -379,8 +409,9 @@ void main() { ), ], ); - when(() => cubit.state) - .thenReturn(const ScriptImportLoaded(scripts: [script])); + when( + () => cubit.state, + ).thenReturn(const ScriptImportLoaded(scripts: [script])); when(() => cubit.removeScript(any())).thenReturn(null); await tester.pumpWidget(_wrap(cubit)); @@ -401,10 +432,10 @@ void main() { await tester.pumpAndSettle(); // Find the CustomPaint that uses _DashedBorderPainter. - final customPaints = - tester.widgetList(find.byType(CustomPaint)); - final dashedWidget = - customPaints.firstWhere((cp) => cp.painter != null); + final customPaints = tester.widgetList( + find.byType(CustomPaint), + ); + final dashedWidget = customPaints.firstWhere((cp) => cp.painter != null); final painter = dashedWidget.painter!; // Same instance → all comparisons evaluate to false → every branch