testsAndMisc-archive/horatio/horatio_app/test/router_test.dart
Krzysztof kuhy Rudnicki 97840b7eea 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
2026-03-29 22:50:24 +02:00

340 lines
10 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:horatio_app/bloc/script_import/script_import_cubit.dart';
import 'package:horatio_app/bloc/srs_review/srs_review_cubit.dart';
import 'package:horatio_app/database/daos/annotation_dao.dart';
import 'package:horatio_app/database/daos/recording_dao.dart';
import 'package:horatio_app/router.dart';
import 'package:horatio_app/services/audio_playback_service.dart';
import 'package:horatio_app/services/recording_service.dart';
import 'package:horatio_app/services/script_repository.dart';
import 'package:horatio_core/horatio_core.dart';
import 'package:mocktail/mocktail.dart';
class _MockAnnotationDao extends Mock implements AnnotationDao {}
class _MockRecordingDao extends Mock implements RecordingDao {}
class _MockRecordingService extends Mock implements RecordingService {}
class _MockAudioPlaybackService extends Mock implements AudioPlaybackService {}
Widget _wrapRouter() {
final repository = ScriptRepository();
final mockDao = _MockAnnotationDao();
final mockRecordingDao = _MockRecordingDao();
final mockRecordingService = _MockRecordingService();
final mockPlaybackService = _MockAudioPlaybackService();
when(
() => mockDao.watchMarksForScript(any()),
).thenAnswer((_) => Stream.value([]));
when(
() => mockDao.watchNotesForScript(any()),
).thenAnswer((_) => Stream.value([]));
when(
() => mockDao.watchSnapshotsForScript(any()),
).thenAnswer((_) => Stream.value([]));
when(
() => mockRecordingDao.watchRecordingsForScript(any()),
).thenAnswer((_) => Stream.value([]));
when(mockRecordingService.hasPermission).thenAnswer((_) async => true);
when(
() => mockRecordingService.startRecording(any()),
).thenAnswer((_) async {});
when(mockRecordingService.stopRecording).thenAnswer((_) async => null);
when(() => mockPlaybackService.play(any())).thenAnswer((_) async {});
when(mockPlaybackService.stop).thenAnswer((_) async {});
when(
() => mockPlaybackService.status,
).thenAnswer((_) => const Stream.empty());
when(
() => mockPlaybackService.position,
).thenAnswer((_) => const Stream.empty());
return MultiRepositoryProvider(
providers: [
RepositoryProvider<ScriptRepository>(create: (_) => repository),
RepositoryProvider<AnnotationDao>.value(value: mockDao),
RepositoryProvider<RecordingDao>.value(value: mockRecordingDao),
RepositoryProvider<RecordingService>.value(value: mockRecordingService),
RepositoryProvider<AudioPlaybackService>.value(
value: mockPlaybackService,
),
RepositoryProvider<String>.value(value: '/tmp/test_recordings'),
],
child: MultiBlocProvider(
providers: [
BlocProvider<ScriptImportCubit>(
create: (_) => ScriptImportCubit(repository: repository),
),
BlocProvider<SrsReviewCubit>(create: (_) => SrsReviewCubit()),
],
child: MaterialApp.router(routerConfig: appRouter),
),
);
}
void main() {
group('Router with valid extras', () {
testWidgets('import route shows ImportScreen', (tester) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
appRouter.go(RoutePaths.import_);
await tester.pumpAndSettle();
expect(find.text('Import Script'), findsOneWidget);
});
testWidgets('role-selection route with Script extra', (tester) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
const role = Role(name: 'Hero');
const script = Script(
id: 'router-valid-id',
title: 'Valid',
roles: [role],
scenes: [
Scene(
lines: [
ScriptLine(
text: 'Line.',
role: role,
sceneIndex: 0,
lineIndex: 0,
),
],
),
],
);
unawaited(appRouter.push(RoutePaths.roleSelection, extra: script));
await tester.pumpAndSettle();
expect(find.text('Choose Your Role'), findsOneWidget);
});
testWidgets('schedule route with map extra', (tester) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
const role = Role(name: 'Hero');
const script = Script(
id: 'router-play-id',
title: 'Play',
roles: [role],
scenes: [
Scene(
lines: [
ScriptLine(text: 'Hi.', role: role, sceneIndex: 0, lineIndex: 0),
],
),
],
);
unawaited(
appRouter.push(
RoutePaths.schedule,
extra: {'script': script, 'role': role},
),
);
await tester.pumpAndSettle();
expect(find.text('Memorization Schedule'), findsOneWidget);
});
testWidgets('rehearsal route with map extra', (tester) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
const role = Role(name: 'Hero');
const script = Script(
id: 'router-rehearse-id',
title: 'Rehearse',
roles: [role],
scenes: [
Scene(
lines: [
ScriptLine(text: 'A.', role: role, sceneIndex: 0, lineIndex: 0),
],
),
],
);
unawaited(
appRouter.push(
RoutePaths.rehearsal,
extra: {'script': script, 'role': role},
),
);
await tester.pumpAndSettle();
expect(find.text('Rehearsing: Hero'), findsOneWidget);
});
testWidgets('srs-review route with cards extra', (tester) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
final cards = [SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans')];
unawaited(appRouter.push(RoutePaths.srsReview, extra: cards));
await tester.pumpAndSettle();
// SrsReviewScreen is visible.
expect(find.text('No review session active.'), findsOneWidget);
});
testWidgets('error route shows 404', (tester) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
appRouter.go('/nonexistent-route');
await tester.pumpAndSettle();
expect(find.text('Not Found'), findsOneWidget);
});
testWidgets('schedule route with wrong extra type falls back', (
tester,
) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
// Push schedule with a non-Map extra → the builder returns SizedBox.
unawaited(appRouter.push(RoutePaths.schedule, extra: 'wrong'));
await tester.pumpAndSettle();
// Should not crash — shows SizedBox.shrink or redirects.
expect(tester.takeException(), isNull);
});
testWidgets('annotations route with Script extra shows editor', (
tester,
) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
// Reset to home to clear any stale navigation stack.
appRouter.go(RoutePaths.home);
await tester.pumpAndSettle();
const role = Role(name: 'Hero');
const script = Script(
id: 'router-annotate-id',
title: 'Annotate Play',
roles: [role],
scenes: [
Scene(
lines: [
ScriptLine(
text: 'Line.',
role: role,
sceneIndex: 0,
lineIndex: 0,
),
],
),
],
);
unawaited(appRouter.push(RoutePaths.annotations, extra: script));
await tester.pumpAndSettle();
expect(find.text('Annotate: Annotate Play'), findsOneWidget);
});
testWidgets('annotations route with null extra redirects home', (
tester,
) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
appRouter.go(RoutePaths.annotations);
await tester.pumpAndSettle();
// Redirected to home.
expect(find.text('Horatio'), findsOneWidget);
});
testWidgets('annotation-history route with Script extra shows history', (
tester,
) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
const role = Role(name: 'Hero');
const script = Script(
id: 'router-history-id',
title: 'History Play',
roles: [role],
scenes: [
Scene(
lines: [
ScriptLine(
text: 'Line.',
role: role,
sceneIndex: 0,
lineIndex: 0,
),
],
),
],
);
unawaited(appRouter.push(RoutePaths.annotationHistory, extra: script));
await tester.pumpAndSettle();
expect(find.text('History: History Play'), findsOneWidget);
});
testWidgets('annotation-history route with null extra redirects home', (
tester,
) async {
await tester.pumpWidget(_wrapRouter());
await tester.pumpAndSettle();
appRouter.go(RoutePaths.annotationHistory);
await tester.pumpAndSettle();
// Redirected to home.
expect(find.text('Horatio'), findsOneWidget);
});
testWidgets('demo route shows DemoAnnotationEditorScreen', (tester) async {
// DemoAnnotationEditorScreen creates a real in-memory Drift DB and
// starts seeding (including speech synthesis) asynchronously.
// All Drift async timers must fire in real time via runAsync.
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();
// Let Drift inserts and the start of speech synthesis run so that
// coverage instruments the synthesis call-site inside _seed.
await Future<void>.delayed(const Duration(milliseconds: 500));
await tester.pump();
// Seeding is in progress — the screen shows a loading indicator.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Replace entire widget tree to trigger 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<void>.delayed(const Duration(milliseconds: 200));
});
});
});
}