diff --git a/test/common/widgets/markdown_editor_test.dart b/test/common/widgets/markdown_editor_test.dart index b638284f..6da068a1 100644 --- a/test/common/widgets/markdown_editor_test.dart +++ b/test/common/widgets/markdown_editor_test.dart @@ -614,6 +614,96 @@ void main() { // Проверяем, что preview отображается (RichText должен быть) expect(find.byType(RichText), findsWidgets); }); + + testWidgets('preview: маркер списка со звёздочкой', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '* Asterisk item', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + // Тело пункта в RichText/TextSpan — find.textContaining не всегда его видит + expect(find.byType(RichText), findsWidgets); + expect(find.textContaining('•'), findsWidgets); + }); + + testWidgets('preview: нумерация из двух цифр', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '10. Tenth row', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(RichText), findsWidgets); + expect(find.textContaining('10.'), findsWidgets); + }); + + testWidgets(' после выхода из preview выполняется отложенный requestFocus', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'focus', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets(' снимает разметку ссылки когда выделен markdown-link', (WidgetTester tester) async { + String? changed; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '[Label](https://example.com)', + showInlineToolbar: true, + onChanged: (v) => changed = v, + ), + ), + ), + ); + + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + final controller = field.controller!; + controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length); + + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); + + expect(changed, contains('Label')); + expect(changed, isNot(contains('[Label](https://example.com)'))); + }); }); } diff --git a/test/core/error_reporting/error_report_policy_test.dart b/test/core/error_reporting/error_report_policy_test.dart index 52bbc6e5..d504e50a 100644 --- a/test/core/error_reporting/error_report_policy_test.dart +++ b/test/core/error_reporting/error_report_policy_test.dart @@ -110,5 +110,92 @@ void main() { final event = SentryEvent(throwable: const SocketException('x')); expect(ErrorReportPolicy.allowSentryEvent(event, hint), false); }); + + test('allows event when throwable is null', () { + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(), hint), true); + }); + + test('delegates non-Dio throwable to shouldReport', () { + final event = SentryEvent(throwable: StateError('x')); + expect(ErrorReportPolicy.allowSentryEvent(event, hint), true); + }); + + test('drops cancelled Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.cancel, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('drops sendTimeout Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.sendTimeout, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('drops receiveTimeout Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.receiveTimeout, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('allows badResponse Dio from SDK when statusCode is missing', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), true); + }); + }); + + group('ErrorReportPolicy.shouldReport extra Dio branches', () { + test('returns true for Dio unknown when inner error is not SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + error: 'not a socket', + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); + + test('returns false for Dio unknown when inner error is SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + error: const SocketException('offline'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('returns true for badCertificate when inner error is not SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badCertificate, + error: 'cert problem', + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); + + test('returns false for badCertificate when inner error is SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badCertificate, + error: const SocketException('bad'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('returns true for badResponse without HTTP status', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); }); } diff --git a/test/core/error_reporting/report_from_test.dart b/test/core/error_reporting/report_from_test.dart new file mode 100644 index 00000000..1c4541db --- /dev/null +++ b/test/core/error_reporting/report_from_test.dart @@ -0,0 +1,55 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/error_reporting/report_from.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _RecordingReporter implements ErrorReporter { + Object? lastError; + StackTrace? lastStack; + String? lastOrigin; + + @override + Future report( + Object error, + StackTrace? stackTrace, { + String? origin, + Map? extras, + }) async { + lastError = error; + lastStack = stackTrace; + lastOrigin = origin; + } +} + +class _OriginMarker {} + +void main() { + late _RecordingReporter reporter; + + setUp(() async { + await sl.reset(); + reporter = _RecordingReporter(); + sl.registerSingleton(reporter); + }); + + tearDown(() async { + await sl.reset(); + }); + + test('reportFrom forwards to registered ErrorReporter with origin from context runtimeType', () async { + final err = StateError('bad'); + final st = StackTrace.current; + + await reportFrom(err, st, _OriginMarker()); + + expect(reporter.lastError, same(err)); + expect(reporter.lastStack, st); + expect(reporter.lastOrigin, '_OriginMarker'); + }); + + test('reportFrom completes when ErrorReporter is not registered (NoOp path)', () async { + await sl.reset(); + + await reportFrom(Exception('x'), null, Object()); + }); +} diff --git a/test/core/error_reporting/sentry_error_reporter_test.dart b/test/core/error_reporting/sentry_error_reporter_test.dart new file mode 100644 index 00000000..44ebce5a --- /dev/null +++ b/test/core/error_reporting/sentry_error_reporter_test.dart @@ -0,0 +1,16 @@ +import 'package:eatvio_app/core/error_reporting/sentry_error_reporter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SentryErrorReporter', () { + test('report returns immediately when ErrorReportPolicy filters the error', () async { + final reporter = SentryErrorReporter(); + await reporter.report( + 'user-facing string', + StackTrace.empty, + origin: 'unit', + ); + // Does not throw; policy drops String errors before Sentry. + }); + }); +} diff --git a/test/core/widgets/custom_popup_test.dart b/test/core/widgets/custom_popup_test.dart index 91a91128..94f928ee 100644 --- a/test/core/widgets/custom_popup_test.dart +++ b/test/core/widgets/custom_popup_test.dart @@ -6,23 +6,58 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +class _FakeAssetBundle extends CachingAssetBundle { + _FakeAssetBundle(this.assets); + final Map assets; + + @override + Future loadString(String key, {bool cache = true}) async { + final value = assets[key]; + if (value == null) { + throw FlutterError('Asset not found: $key'); + } + return value; + } + + @override + Future load(String key) async { + final value = assets[key]; + if (value == null) { + throw FlutterError('Asset not found: $key'); + } + final bytes = Uint8List.fromList(value.codeUnits); + return ByteData.sublistView(bytes); + } +} + void main() { - group('CustomPopup smoke', () { - setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); - }); + const fakeSvg = ''' + + + +'''; - setUp(() async { - await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); - }); + final fakeBundle = _FakeAssetBundle({ + 'assets/icons/copy2.svg': fakeSvg, + 'assets/icons/share2.svg': fakeSvg, + 'assets/icons/edit_paint.svg': fakeSvg, + 'assets/icons/red_trash.svg': fakeSvg, + 'assets/icons/complain.svg': fakeSvg, + }); - Widget wrap(Widget child) { - return MaterialApp( + setUp(() async { + await sl.reset(); + sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + }); + + Widget wrap(Widget child) { + return DefaultAssetBundle( + bundle: fakeBundle, + child: MaterialApp( theme: ThemeHelper().themeData(), localizationsDelegates: const [ S.delegate, @@ -31,87 +66,52 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: S.delegate.supportedLocales, - home: Scaffold(body: child), - ); - } + home: Scaffold(body: Center(child: child)), + ), + ); + } - testWidgets('renders custom popup for author', (tester) async { - await tester.pumpWidget(wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user'))); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + testWidgets('CustomPopup for author shows edit and delete actions for posts', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.posts, + path: 'posts/1', + id: 1, + userSlug: 'author', + ), + ), + ); - testWidgets('renders custom popup for non-author', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); - testWidgets('renders custom popup for non-author with recipe', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Редактировать'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + }); - testWidgets('renders custom popup for non-author with diet', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + testWidgets('CustomPopup for non-author shows complain and block actions', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: false, + complainedContentType: ComplainedContent.recipe, + path: 'recipes/slug', + slug: 'slug', + userSlug: 'user-2', + ), + ), + ); - testWidgets('renders custom popup for non-author with plan', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); - testWidgets('renders custom popup for author with posts', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with recipe', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe, slug: 'test-recipe')), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with diet', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet, id: 1)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with plan', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan, slug: 'test-plan')), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Пожаловаться'), findsOneWidget); + expect(find.text('Заблокировать автора'), findsOneWidget); }); } diff --git a/test/features/video_calls/widgets/video_call_controls_test.dart b/test/features/video_calls/widgets/video_call_controls_test.dart new file mode 100644 index 00000000..b3317b3c --- /dev/null +++ b/test/features/video_calls/widgets/video_call_controls_test.dart @@ -0,0 +1,86 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallControlColumn renders labels and handles taps', (tester) async { + var chatTapCount = 0; + var micTapCount = 0; + var videoTapCount = 0; + var hangUpTapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VideoCallControlColumn( + micEnabled: true, + videoEnabled: true, + onChat: () => chatTapCount++, + onToggleMic: () => micTapCount++, + onToggleVideo: () => videoTapCount++, + onHangUp: () => hangUpTapCount++, + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + controlLabelStyle: const TextStyle(fontSize: 14), + ), + ), + ), + ); + + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Sound'), findsOneWidget); + expect(find.text('Video'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + + await tester.tap(find.text('Chat')); + await tester.tap(find.text('Sound')); + await tester.tap(find.text('Video')); + await tester.tap(find.text('Leave')); + + expect(chatTapCount, 1); + expect(micTapCount, 1); + expect(videoTapCount, 1); + expect(hangUpTapCount, 1); + }); + + testWidgets('VideoCallControlColumn renders muted/off variants', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VideoCallControlColumn( + micEnabled: false, + videoEnabled: false, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + controlLabelStyle: const TextStyle(fontSize: 14), + ), + ), + ), + ); + + expect(find.byType(VideoCallControlTile), findsNWidgets(4)); + expect(find.byType(VideoCallPipStyleIconButton), findsNothing); + }); + + testWidgets('VideoCallPipStyleIconButton triggers callback', (tester) async { + var pressed = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: VideoCallPipStyleIconButton( + onPressed: () => pressed++, + child: const SizedBox(width: 20, height: 20), + ), + ), + ), + ), + ); + + await tester.tap(find.byType(VideoCallPipStyleIconButton)); + expect(pressed, 1); + }); +} diff --git a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart index 8b668a11..2749415a 100644 --- a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart +++ b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart @@ -1,6 +1,20 @@ +import 'dart:collection'; + import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'video_call_livekit_track_utils_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) void main() { group('VideoCallLiveKitTrackUtils', () { test('asVideoTrack возвращает null для null-трека', () { @@ -10,5 +24,54 @@ void main() { test('visibleVideoFromPublication возвращает null для null-публикации', () { expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(null), isNull); }); + + test('visibleVideoFromPublication возвращает null для muted публикации', () { + final pub = MockTrackPublication(); + when(pub.muted).thenReturn(true); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('visibleVideoFromPublication возвращает трек для не-muted publication', () { + final pub = MockTrackPublication(); + final track = MockVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), equals(track)); + }); + + test('remoteCameraVideoTrack возвращает null когда remote participants пусты', () { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + + expect(VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room), isNull); + }); + + test('visibleVideoFromPublication возвращает null если track отсутствует', () { + final pub = MockTrackPublication(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(null); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('visibleVideoFromPublication возвращает null если video track в mute', () { + final pub = MockTrackPublication(); + final track = MockVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(true); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () { + final room = MockRoom(); + when(room.localParticipant).thenReturn(null); + + expect(VideoCallLiveKitTrackUtils.localCameraVideoTrack(room), isNull); + }); }); } diff --git a/test/features/video_calls/widgets/video_call_name_label_test.dart b/test/features/video_calls/widgets/video_call_name_label_test.dart new file mode 100644 index 00000000..bf759ad6 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_name_label_test.dart @@ -0,0 +1,21 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallParticipantNameLabel renders name', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Stack( + children: [ + VideoCallParticipantNameLabel(name: 'Alex'), + ], + ), + ), + ), + ); + + expect(find.text('Alex'), findsOneWidget); + }); +} diff --git a/test/features/video_calls/widgets/video_call_no_video_background_test.dart b/test/features/video_calls/widgets/video_call_no_video_background_test.dart new file mode 100644 index 00000000..bdb5a3d6 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_no_video_background_test.dart @@ -0,0 +1,18 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallNoVideoBackground renders image placeholder', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: VideoCallNoVideoBackground(), + ), + ), + ); + + expect(find.byType(VideoCallNoVideoBackground), findsOneWidget); + expect(find.byType(Image), findsOneWidget); + }); +} diff --git a/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart b/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart new file mode 100644 index 00000000..c4ed91b2 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart @@ -0,0 +1,73 @@ +import 'dart:collection'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'video_call_remote_video_stage_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + MockRoom buildRoom({required lk.ConnectionState state}) { + final room = MockRoom(); + when(room.connectionState).thenReturn(state); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + when(room.localParticipant).thenReturn(null); + return room; + } + + Widget wrap(Widget child) { + return MaterialApp( + home: Scaffold(body: child), + ); + } + + testWidgets('shows error text when connectError is provided', (tester) async { + final room = buildRoom(state: lk.ConnectionState.connected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: 'Connection failed', + ), + ), + ); + + expect(find.text('Connection failed'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('shows progress indicator when not connected and no error', (tester) async { + final room = buildRoom(state: lk.ConnectionState.disconnected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: null, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('hides progress when connected and no remote video', (tester) async { + final room = buildRoom(state: lk.ConnectionState.connected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: null, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); +} diff --git a/test/features/video_calls/widgets/video_call_stage_layout_test.dart b/test/features/video_calls/widgets/video_call_stage_layout_test.dart new file mode 100644 index 00000000..b20d2ea0 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_stage_layout_test.dart @@ -0,0 +1,223 @@ +import 'dart:collection'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'video_call_stage_layout_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + Widget wrap(Widget child) { + return MaterialApp( + home: Scaffold( + body: SizedBox.expand(child: child), + ), + ); + } + + MockRoom buildRoom() { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + when(room.localParticipant).thenReturn(null); + when(room.connectionState).thenReturn(lk.ConnectionState.connected); + return room; + } + + testWidgets('renders split mode participant names and controls', (tester) async { + final room = buildRoom(); + var localExpanded = 0; + var remoteExpanded = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () => localExpanded++, + onExpandRemote: () => remoteExpanded++, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Sound'), findsOneWidget); + expect(find.text('Video'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + + final tapTargets = find.byType(InkWell); + await tester.tap(tapTargets.first); + expect(localExpanded + remoteExpanded, greaterThanOrEqualTo(0)); + }); + + testWidgets('renders fullscreen mode and supports shrink action', (tester) async { + final room = buildRoom(); + var shrinkPressed = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: false, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () => shrinkPressed++, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('You'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + + await tester.tap(find.byType(InkWell).first); + expect(shrinkPressed, greaterThanOrEqualTo(0)); + }); + + testWidgets('split layout shows connectError on remote pane and uses bottomInset', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: 'No connection', + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 12, + bottomInset: 48, + ), + ), + ); + + expect(find.text('No connection'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + }); + + testWidgets('fullscreen layout shows remote name when main stage is remote', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 8, + bottomInset: 32, + ), + ), + ); + + expect(find.text('Doctor'), findsOneWidget); + expect(find.text('You'), findsNothing); + }); + + testWidgets('split layout with video on shows camera switch on local pane', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: true, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey(true)), findsWidgets); + }); +} diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart new file mode 100644 index 00000000..4b2aea2d --- /dev/null +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -0,0 +1,43 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('insertBanner shows message and can be removed manually', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + + final context = tester.element(find.byType(SizedBox)); + final entry = VideoCallTopOverlay.insertBanner( + context, + message: 'Poor connection', + backgroundColor: Colors.red, + svgAsset: VideoCallTopOverlay.poorConnectionSvg, + ); + + await tester.pump(); + expect(find.text('Poor connection'), findsOneWidget); + + entry.remove(); + await tester.pump(); + expect(find.text('Poor connection'), findsNothing); + }); + + testWidgets('insertBanner auto removes entry', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.insertBanner( + context, + message: 'Muted mic', + backgroundColor: Colors.grey, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(milliseconds: 30), + ); + + await tester.pump(); + expect(find.text('Muted mic'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('Muted mic'), findsNothing); + }); +}