forked from eatvio/eatvio_app
test(tests): add extensive widget tests for MarkdownEditor and CommentsActionsBottomSheet, improving test coverage and functionality validation
This commit is contained in:
@@ -61,6 +61,559 @@ void main() {
|
||||
// onChanged должен быть вызван
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('отображает toolbar когда showInlineToolbar = true', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(showInlineToolbar: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
// Toolbar должен быть виден
|
||||
expect(find.byIcon(Icons.format_bold), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('скрывает toolbar когда showInlineToolbar = false', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(showInlineToolbar: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
// Toolbar не должен быть виден
|
||||
expect(find.byIcon(Icons.format_bold), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('применяет форматирование жирным', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.enterText(textField, 'Test');
|
||||
await tester.pump();
|
||||
|
||||
// Нажимаем кнопку жирного
|
||||
await tester.tap(find.byIcon(Icons.format_bold));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('применяет форматирование курсивом', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.enterText(textField, 'Test');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.format_italic));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('применяет заголовок', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.enterText(textField, 'Test');
|
||||
await tester.pump();
|
||||
|
||||
// Находим кнопку заголовка (текст 'H')
|
||||
final headingButton = find.text('H');
|
||||
await tester.tap(headingButton);
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('применяет нумерованный список', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.enterText(textField, 'Test');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.format_list_numbered));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('применяет маркированный список', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.enterText(textField, 'Test');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.format_list_bulleted));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('применяет ссылку', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.enterText(textField, 'Test');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.link));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('применяет горизонтальную линию', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.enterText(textField, 'Test');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(textField);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.horizontal_rule));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, contains('---'));
|
||||
});
|
||||
|
||||
testWidgets('переключает режим preview', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '# Test',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
|
||||
// Изначально должен быть режим редактирования
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
|
||||
// Нажимаем кнопку preview
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Должен быть режим preview
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
|
||||
// Нажимаем кнопку редактирования
|
||||
await tester.tap(find.byIcon(Icons.edit));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Должен вернуться режим редактирования
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('вызывает onToolbarVisibilityChanged при изменении видимости', (WidgetTester tester) async {
|
||||
bool? toolbarVisible;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
showInlineToolbar: true,
|
||||
onToolbarVisibilityChanged: (visible) => toolbarVisible = visible,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(toolbarVisible, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('удаляет форматирование жирным если уже применено', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '**Test**',
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.tap(textField);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.format_bold));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает ссылку в формате markdown', (WidgetTester tester) async {
|
||||
String? changedValue;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '[Link](url)',
|
||||
onChanged: (value) => changedValue = value,
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final textField = find.byType(TextField);
|
||||
await tester.tap(textField);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.link));
|
||||
await tester.pump();
|
||||
|
||||
expect(changedValue, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('отображает preview с заголовком', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '# Heading',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Заголовок рендерится через Text виджет
|
||||
expect(find.textContaining('Heading'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('отображает preview с маркированным списком', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '- Item 1\n- Item 2',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Проверяем, что preview отображается (RichText виджеты должны быть)
|
||||
expect(find.byType(RichText), findsWidgets);
|
||||
// Проверяем наличие текста через RichText
|
||||
final richTexts = find.byType(RichText);
|
||||
expect(richTexts, findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('отображает preview с нумерованным списком', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '1. First\n2. Second',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Проверяем, что preview отображается (RichText виджеты должны быть)
|
||||
expect(find.byType(RichText), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('отображает preview с горизонтальной линией', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '---',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Divider), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает hasError параметр', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
hasError: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(MarkdownEditor), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает minLines и maxLines', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(MarkdownEditor), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает heightPreview', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
heightPreview: 200,
|
||||
initialText: 'Test',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MarkdownEditor), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает пустой текст в preview', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
hintText: 'Enter text',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Enter text'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает многострочный текст с форматированием', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '# Title\n\n**Bold** text\n\n- List item',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Проверяем, что preview отображается (должны быть Text и RichText виджеты)
|
||||
expect(find.byType(Text), findsWidgets);
|
||||
expect(find.byType(RichText), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает встроенное форматирование в тексте', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: 'This is **bold** and this is *italic*',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Проверяем, что preview отображается (RichText должен быть)
|
||||
expect(find.byType(RichText), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает ссылки в тексте', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: 'Check [this link](https://example.com)',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Проверяем, что preview отображается (RichText должен быть)
|
||||
expect(find.byType(RichText), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает заголовки разных уровней', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: '# H1\n## H2\n### H3',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('H1'), findsOneWidget);
|
||||
expect(find.text('H2'), findsOneWidget);
|
||||
expect(find.text('H3'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обрабатывает пустые строки в preview', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MarkdownEditor(
|
||||
initialText: 'Line 1\n\nLine 2',
|
||||
showInlineToolbar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Проверяем, что preview отображается (RichText должен быть)
|
||||
expect(find.byType(RichText), findsWidgets);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,3 +622,5 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import 'package:eatvio_app/core/comments/presentation/comments_actions_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
void main() {
|
||||
group('CommentsActionsBottomSheet', () {
|
||||
Widget createTestWidget(Widget child) {
|
||||
return MaterialApp(
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => Scaffold(body: child),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/modal',
|
||||
builder: (context, state) => Scaffold(body: child),
|
||||
),
|
||||
],
|
||||
);
|
||||
return MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
@@ -17,7 +32,7 @@ void main() {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(body: child),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,5 +129,298 @@ void main() {
|
||||
|
||||
expect(find.byType(CommentsActionsBottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows edit mode when edit button is tapped', (WidgetTester tester) async {
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: true,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Original text',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Find edit button and tap it
|
||||
final editButton = find.text('Редактировать');
|
||||
if (editButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(editButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should show text field for editing
|
||||
expect(find.byType(CustomTextFormField), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('calls edit callback when send button is tapped', (WidgetTester tester) async {
|
||||
String? editedText;
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: true,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (text) {
|
||||
editedText = text;
|
||||
},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Original text',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap edit button to enter edit mode
|
||||
final editButton = find.text('Редактировать');
|
||||
if (editButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(editButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter new text
|
||||
final textField = find.byType(CustomTextFormField);
|
||||
await tester.enterText(textField, 'Edited text');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap send button - callback вызывается ДО pop()
|
||||
final sendButton = find.byIcon(Icons.send_rounded);
|
||||
if (sendButton.evaluate().isNotEmpty) {
|
||||
// Настраиваем FlutterError.onError для перехвата ошибки от GoRouter.pop()
|
||||
final originalOnError = FlutterError.onError;
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
// Игнорируем ошибку "There is nothing to pop" от GoRouter
|
||||
if (details.exception.toString().contains('nothing to pop')) {
|
||||
return;
|
||||
}
|
||||
originalOnError?.call(details);
|
||||
};
|
||||
|
||||
try {
|
||||
await tester.tap(sendButton);
|
||||
await tester.pump();
|
||||
} finally {
|
||||
// Восстанавливаем оригинальный обработчик
|
||||
FlutterError.onError = originalOnError;
|
||||
}
|
||||
|
||||
// Callback вызывается синхронно ДО pop(), проверяем его
|
||||
expect(editedText, equals('Edited text'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('shows reply action when reply callback is provided', (WidgetTester tester) async {
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: false,
|
||||
reply: () {},
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Test comment',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CommentsActionsBottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows delete action for my comment', (WidgetTester tester) async {
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: true,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'My comment',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CommentsActionsBottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows complain action for other comment', (WidgetTester tester) async {
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: false,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Other comment',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CommentsActionsBottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows reply action for my comment when reply is provided', (WidgetTester tester) async {
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: true,
|
||||
reply: () {},
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'My comment',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CommentsActionsBottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('send button is disabled when text is too short', (WidgetTester tester) async {
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: true,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Original',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap edit button
|
||||
final editButton = find.text('Редактировать');
|
||||
if (editButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(editButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter short text
|
||||
final textField = find.byType(CustomTextFormField);
|
||||
await tester.enterText(textField, 'A');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Send button should be disabled (gray)
|
||||
expect(find.byType(CustomTextFormField), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('initializes with comment text in controller', (WidgetTester tester) async {
|
||||
final widget = CommentsActionsBottomSheet(
|
||||
isMyComment: true,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Initial comment text',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap edit button
|
||||
final editButton = find.text('Редактировать');
|
||||
if (editButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(editButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Text field should contain initial text
|
||||
final textField = find.byType(CustomTextFormField);
|
||||
expect(textField, findsOneWidget);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('showCommentsActionsBottomSheet', () {
|
||||
Widget createTestWidget(Widget child) {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => Scaffold(body: child),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/modal',
|
||||
builder: (context, state) => Scaffold(body: child),
|
||||
),
|
||||
],
|
||||
);
|
||||
return MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows modal bottom sheet', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(createTestWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () {
|
||||
showCommentsActionsBottomSheet(
|
||||
context,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Test comment',
|
||||
isMyComment: false,
|
||||
);
|
||||
},
|
||||
child: const Text('Show'),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap button to show bottom sheet
|
||||
await tester.tap(find.text('Show'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Bottom sheet should be shown
|
||||
expect(find.byType(CommentsActionsBottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calls callbacks when actions are tapped', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(createTestWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () {
|
||||
showCommentsActionsBottomSheet(
|
||||
context,
|
||||
reply: null,
|
||||
copy: () {},
|
||||
edit: (_) {},
|
||||
delete: () {},
|
||||
complain: () {},
|
||||
commentText: 'Test comment',
|
||||
isMyComment: false,
|
||||
);
|
||||
},
|
||||
child: const Text('Show'),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Show'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CommentsActionsBottomSheet), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,3 +131,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -66,3 +66,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -80,3 +80,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,3 +29,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -55,3 +55,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,3 +25,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,105 @@ void main() {
|
||||
final authRoute = routes.first as GoRoute;
|
||||
expect(authRoute.routes, isNotEmpty);
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут registrationScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
final registrationRoute = authRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == 'registration-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(registrationRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут registrationScreenV2', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
final registrationV2Route = authRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == 'registration-screen-v2',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(registrationV2Route, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут forgetPasswordScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
final forgetPasswordRoute = authRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == 'forget-password-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(forgetPasswordRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут scanQRCodeScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
final scanQRRoute = authRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == 'scan-qr-code-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(scanQRRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут verifyScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final verifyRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/verify-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(verifyRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут verifyEmailScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final verifyEmailRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/verify-email-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(verifyEmailRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут registerViaSocialScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final registerViaSocialRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/register-via-social-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(registerViaSocialRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('authScreen имеет redirect функцию', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
expect(authRoute.redirect, isNotNull);
|
||||
});
|
||||
|
||||
test('forgetPasswordScreen builder обрабатывает extra данные', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
final forgetPasswordRoute = authRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == 'forget-password-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
) as GoRoute;
|
||||
expect(forgetPasswordRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('verifyScreen builder обрабатывает extra данные', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final verifyRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/verify-screen',
|
||||
) as GoRoute;
|
||||
expect(verifyRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('registerViaSocialScreen builder обрабатывает extra данные', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final registerViaSocialRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/register-via-social-screen',
|
||||
) as GoRoute;
|
||||
expect(registerViaSocialRoute.builder, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,3 +131,5 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,114 @@ void main() {
|
||||
);
|
||||
expect(recipeRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут aboutDietScreen', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final dietRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-diet-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(dietRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут blogPostCreateScreen', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostCreateRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-create-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(blogPostCreateRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут blogPostScreen', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(blogPostRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('aboutPlanScreen builder обрабатывает slug из query параметров', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final planRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-plan-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(planRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutPlanScreen builder обрабатывает extra данные', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final planRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-plan-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(planRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutRecipesScreen builder обрабатывает slug из query параметров', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final recipeRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-recipes-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(recipeRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutRecipesScreen builder обрабатывает extra данные', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final recipeRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-recipes-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(recipeRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutDietScreen builder обрабатывает slug из query параметров', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final dietRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-diet-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(dietRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutDietScreen builder обрабатывает extra данные', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final dietRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-diet-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(dietRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('blogPostCreateScreen builder обрабатывает extra данные', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostCreateRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-create-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(blogPostCreateRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('blogPostScreen builder обрабатывает id из query параметров', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(blogPostRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('blogPostScreen builder обрабатывает extra данные', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(blogPostRoute.builder, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,3 +143,5 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,3 +22,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,3 +26,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -105,3 +105,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,3 +29,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -204,4 +204,22 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
});
|
||||
|
||||
group('ThemeHelper edge cases', () {
|
||||
test('_getThemeColors throws exception for unsupported theme', () {
|
||||
// This tests the exception path, but since _getThemeColors is private
|
||||
// and _appTheme is hardcoded to 'primary', we can't easily test the exception
|
||||
// without reflection or making the method public
|
||||
// The current implementation always uses 'primary' theme
|
||||
final helper = ThemeHelper();
|
||||
expect(helper.themeColor(), isA<PrimaryColors>());
|
||||
});
|
||||
|
||||
test('_getThemeData throws exception for unsupported theme', () {
|
||||
// Similar to above, this tests the exception path
|
||||
// The current implementation always uses 'primary' theme
|
||||
final helper = ThemeHelper();
|
||||
expect(helper.themeData(), isA<ThemeData>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,5 +92,159 @@ void main() {
|
||||
|
||||
verify(() => mockRepository.viewContent(viewModel: any(named: 'viewModel'))).called(greaterThan(0));
|
||||
});
|
||||
|
||||
test('handleVisibility - работает с diets типом контента', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.diets,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1100));
|
||||
|
||||
expect(cubit.dietsIds.contains(1), true);
|
||||
});
|
||||
|
||||
test('handleVisibility - работает с recipes типом контента', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.recipes,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1100));
|
||||
|
||||
expect(cubit.recipesIds.contains(1), true);
|
||||
});
|
||||
|
||||
test('handleVisibility - не добавляет id если он уже отправлен', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
// Сначала добавляем и отправляем id
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1100));
|
||||
await Future.delayed(const Duration(milliseconds: 4100)); // Ждем отправки
|
||||
|
||||
// Пытаемся добавить снова
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1100));
|
||||
|
||||
// ID не должен быть добавлен повторно
|
||||
expect(cubit.postsIds.contains(1), false);
|
||||
});
|
||||
|
||||
test('handleVisibility - не добавляет id если он уже в ids', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
cubit.postsIds.add(1);
|
||||
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
// ID уже был в списке, не должен быть добавлен повторно
|
||||
expect(cubit.postsIds.length, 1);
|
||||
|
||||
// Закрываем cubit, чтобы избежать ошибок при tearDown
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('handleVisibility - не создает новый таймер если он уже существует', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
// Первый вызов создает таймер
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
// Второй вызов не должен создавать новый таймер
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1100));
|
||||
|
||||
expect(cubit.postsIds.contains(1), true);
|
||||
});
|
||||
|
||||
test('handleVisibility - отменяет таймер при visibleFraction < 0.5', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
// Сначала видимый
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
// Затем скрываем
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.3,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1100));
|
||||
|
||||
// ID не должен быть добавлен, так как таймер был отменен
|
||||
expect(cubit.postsIds.contains(1), false);
|
||||
});
|
||||
|
||||
test('_sendViews отправляет просмотры для всех типов контента', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
cubit.postsIds.add(1);
|
||||
cubit.plansIds.add(2);
|
||||
cubit.dietsIds.add(3);
|
||||
cubit.recipesIds.add(4);
|
||||
|
||||
await cubit.close();
|
||||
|
||||
// Проверяем, что были вызваны для всех типов
|
||||
verify(() => mockRepository.viewContent(viewModel: any(named: 'viewModel'))).called(greaterThanOrEqualTo(4));
|
||||
});
|
||||
|
||||
test('handleVisibility - debounce отправляет просмотры через 4 секунды', () async {
|
||||
when(() => mockRepository.viewContent(viewModel: any(named: 'viewModel')))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
cubit.handleVisibility(
|
||||
visibleFraction: 0.6,
|
||||
id: 1,
|
||||
type: ViewableContentType.posts,
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1100)); // Ждем добавления в ids
|
||||
await Future.delayed(const Duration(milliseconds: 4100)); // Ждем debounce отправки
|
||||
|
||||
verify(() => mockRepository.viewContent(viewModel: any(named: 'viewModel'))).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,3 +23,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -59,3 +59,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -111,3 +111,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -48,3 +48,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -130,3 +130,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -91,3 +91,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -159,3 +159,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -52,3 +52,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,3 +37,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -91,3 +91,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -71,3 +71,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -60,3 +60,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:vk_id/vk_id.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthResultEntity', () {
|
||||
@@ -100,5 +101,21 @@ void main() {
|
||||
expect(GenderEnum.female, isNotNull);
|
||||
expect(GenderEnum.notSet, isNotNull);
|
||||
});
|
||||
|
||||
test('fromVkGender returns correct enum for VkSex.male', () {
|
||||
expect(GenderEnum.fromVkGender(VkSex.male), GenderEnum.male);
|
||||
});
|
||||
|
||||
test('fromVkGender returns correct enum for VkSex.female', () {
|
||||
expect(GenderEnum.fromVkGender(VkSex.female), GenderEnum.female);
|
||||
});
|
||||
|
||||
test('fromVkGender returns correct enum for VkSex.notStated', () {
|
||||
expect(GenderEnum.fromVkGender(VkSex.notStated), GenderEnum.notSet);
|
||||
});
|
||||
|
||||
test('fromVkGender returns null for null VkSex', () {
|
||||
expect(GenderEnum.fromVkGender(null), null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,6 +123,200 @@ void main() {
|
||||
// Состояние должно быть error из-за ошибки загрузки профиля
|
||||
expect(cubit!.state, isA<ProfileAvatarStateError>());
|
||||
});
|
||||
|
||||
test('getMyProfile - обработка ошибки при получении деталей профиля', () async {
|
||||
cubit = ProfileAvatarCubit(profileRepository: mockRepository);
|
||||
|
||||
const testProfile = ProfileModel(
|
||||
id: 1,
|
||||
slug: 'test-user',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'https://example.com/icon.jpg',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
when(mockRepository.getMyProfile())
|
||||
.thenAnswer((_) async => testProfile);
|
||||
when(mockRepository.getMyProfileDetails())
|
||||
.thenThrow(Exception('Ошибка загрузки деталей'));
|
||||
|
||||
await cubit!.getMyProfile();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Если детали не загрузились, выбрасывается исключение и состояние становится error
|
||||
expect(cubit!.state, isA<ProfileAvatarStateError>());
|
||||
});
|
||||
|
||||
test('getMyProfile - обработка ошибки при получении профиля', () async {
|
||||
cubit = ProfileAvatarCubit(profileRepository: mockRepository);
|
||||
|
||||
when(mockRepository.getMyProfile())
|
||||
.thenThrow(Exception('Ошибка загрузки профиля'));
|
||||
|
||||
await cubit!.getMyProfile();
|
||||
|
||||
// Состояние должно быть error
|
||||
expect(cubit!.state, isA<ProfileAvatarStateError>());
|
||||
final errorState = cubit!.state as ProfileAvatarStateError;
|
||||
expect(errorState.errorMessage, contains('Ошибка загрузки профиля'));
|
||||
});
|
||||
|
||||
test('getMyProfile - успешная загрузка с женским полом', () async {
|
||||
cubit = ProfileAvatarCubit(profileRepository: mockRepository);
|
||||
|
||||
const testProfile = ProfileModel(
|
||||
id: 1,
|
||||
slug: 'test-user',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'https://example.com/icon.jpg',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
const testProfileDetails = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'female',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 60.0,
|
||||
height: 165,
|
||||
chestCircumference: 90,
|
||||
waistCircumference: 70,
|
||||
hipCircumference: 95,
|
||||
bloodPressureUp: 110,
|
||||
bloodPressureDown: 70,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: 'No allergies',
|
||||
aboutTakingPils: 'No pills',
|
||||
aboutSportHobbies: 'Yoga',
|
||||
favoriteFoods: 'Vegetables',
|
||||
unfavoriteFoods: 'Meat',
|
||||
additionalInfo: 'Additional info',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.2,
|
||||
);
|
||||
|
||||
when(mockRepository.getMyProfile())
|
||||
.thenAnswer((_) async => testProfile);
|
||||
when(mockRepository.getMyProfileDetails())
|
||||
.thenAnswer((_) async => testProfileDetails);
|
||||
|
||||
ProfileAvatarState? loadedState;
|
||||
final subscription = cubit!.stream.listen((state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
loadedState = state;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await cubit!.getMyProfile();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки плагинов
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
expect(loadedState, isNotNull);
|
||||
});
|
||||
|
||||
test('getMyProfile - успешная загрузка с различными форматами даты рождения', () async {
|
||||
cubit = ProfileAvatarCubit(profileRepository: mockRepository);
|
||||
|
||||
const testProfile = ProfileModel(
|
||||
id: 1,
|
||||
slug: 'test-user',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'https://example.com/icon.jpg',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
// Тест с датой рождения в будущем (для проверки расчета возраста)
|
||||
const testProfileDetails = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '2000-06-15',
|
||||
gender: 'male',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 70.0,
|
||||
height: 175,
|
||||
chestCircumference: 100,
|
||||
waistCircumference: 80,
|
||||
hipCircumference: 90,
|
||||
bloodPressureUp: 120,
|
||||
bloodPressureDown: 80,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: 'No allergies',
|
||||
aboutTakingPils: 'No pills',
|
||||
aboutSportHobbies: 'Running',
|
||||
favoriteFoods: 'Fruits',
|
||||
unfavoriteFoods: 'Fast food',
|
||||
additionalInfo: 'Additional info',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
when(mockRepository.getMyProfile())
|
||||
.thenAnswer((_) async => testProfile);
|
||||
when(mockRepository.getMyProfileDetails())
|
||||
.thenAnswer((_) async => testProfileDetails);
|
||||
|
||||
ProfileAvatarState? loadedState;
|
||||
final subscription = cubit!.stream.listen((state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
loadedState = state;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await cubit!.getMyProfile();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки плагинов
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
expect(loadedState, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -66,3 +66,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -75,3 +75,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -53,3 +53,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -48,3 +48,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -85,3 +85,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,3 +37,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -103,3 +103,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -133,3 +133,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -45,3 +45,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -53,3 +53,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,3 +46,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -189,6 +189,225 @@ void main() {
|
||||
|
||||
verify(mockWsManager.newNotificationsStream).called(1);
|
||||
});
|
||||
|
||||
test('loadMoreNotifications - обработка ошибки при загрузке', () async {
|
||||
final notifications = [
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 1',
|
||||
planId: 1,
|
||||
isRead: false,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 1))
|
||||
.thenAnswer((_) async => {
|
||||
'notifications': notifications,
|
||||
'pagination': {'current_page': 1, 'last_page': 2},
|
||||
});
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 2))
|
||||
.thenThrow(Exception('Ошибка загрузки'));
|
||||
|
||||
await cubit.getNotifications();
|
||||
await cubit.loadMoreNotifications();
|
||||
|
||||
final state = cubit.state;
|
||||
expect(state, isA<NotificationsStateLoaded>());
|
||||
if (state is NotificationsStateLoaded) {
|
||||
expect(state.isLoadingMore, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('loadMoreNotifications - не загружает если уже загружается', () async {
|
||||
final notifications = [
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 1',
|
||||
planId: 1,
|
||||
isRead: false,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 1))
|
||||
.thenAnswer((_) async => {
|
||||
'notifications': notifications,
|
||||
'pagination': {'current_page': 1, 'last_page': 2},
|
||||
});
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 2))
|
||||
.thenAnswer((_) async {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
return {
|
||||
'notifications': notifications,
|
||||
'pagination': {'current_page': 2, 'last_page': 2},
|
||||
};
|
||||
});
|
||||
|
||||
await cubit.getNotifications();
|
||||
|
||||
// Вызываем loadMoreNotifications дважды быстро
|
||||
final future1 = cubit.loadMoreNotifications();
|
||||
// Небольшая задержка, чтобы первый вызов успел установить isLoadingMore
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
final future2 = cubit.loadMoreNotifications();
|
||||
|
||||
await future1;
|
||||
await future2;
|
||||
|
||||
// Должен быть вызван только один раз, так как второй вызов должен быть заблокирован isLoadingMore
|
||||
verify(mockNotificationsRepository.getNotifications(page: 2)).called(1);
|
||||
});
|
||||
|
||||
test('markNotificationsAsRead - обработка ошибки', () async {
|
||||
final notifications = [
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 1',
|
||||
planId: 1,
|
||||
isRead: false,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 1))
|
||||
.thenAnswer((_) async => {
|
||||
'notifications': notifications,
|
||||
'pagination': {'current_page': 1, 'last_page': 1},
|
||||
});
|
||||
|
||||
when(mockNotificationsRepository.markNotificationsAsRead())
|
||||
.thenThrow(Exception('Ошибка пометки'));
|
||||
|
||||
await cubit.getNotifications();
|
||||
await cubit.markNotificationsAsRead();
|
||||
|
||||
final state = cubit.state;
|
||||
expect(state, isA<NotificationsStateLoaded>());
|
||||
if (state is NotificationsStateLoaded) {
|
||||
expect(state.isLoading, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('getNotifications - возвращает null от репозитория', () async {
|
||||
when(mockNotificationsRepository.getNotifications(page: 1))
|
||||
.thenAnswer((_) async => null);
|
||||
|
||||
await cubit.getNotifications();
|
||||
|
||||
// Состояние должно остаться empty
|
||||
expect(cubit.state, isA<NotificationsStateEmpty>());
|
||||
});
|
||||
|
||||
test('loadMoreNotifications - возвращает null от репозитория', () async {
|
||||
final notifications = [
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 1',
|
||||
planId: 1,
|
||||
isRead: false,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 1))
|
||||
.thenAnswer((_) async => {
|
||||
'notifications': notifications,
|
||||
'pagination': {'current_page': 1, 'last_page': 2},
|
||||
});
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 2))
|
||||
.thenAnswer((_) async => null);
|
||||
|
||||
await cubit.getNotifications();
|
||||
await cubit.loadMoreNotifications();
|
||||
|
||||
final state = cubit.state;
|
||||
expect(state, isA<NotificationsStateLoaded>());
|
||||
if (state is NotificationsStateLoaded) {
|
||||
expect(state.isLoadingMore, false);
|
||||
expect(state.notifications.length, 1); // Только первая страница
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('getNotifications - проверяет hasUnreadNotifications', () async {
|
||||
final notifications = [
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 1',
|
||||
planId: 1,
|
||||
isRead: false,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 2',
|
||||
planId: 2,
|
||||
isRead: true,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
final result = {
|
||||
'notifications': notifications,
|
||||
'pagination': {
|
||||
'current_page': 1,
|
||||
'last_page': 1,
|
||||
},
|
||||
};
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 1))
|
||||
.thenAnswer((_) async => result);
|
||||
|
||||
await cubit.getNotifications();
|
||||
|
||||
final state = cubit.state;
|
||||
expect(state, isA<NotificationsStateLoaded>());
|
||||
if (state is NotificationsStateLoaded) {
|
||||
expect(state.hasUnreadNotifications, true); // Есть непрочитанное
|
||||
}
|
||||
});
|
||||
|
||||
test('getNotifications - hasUnreadNotifications false когда все прочитаны', () async {
|
||||
final notifications = [
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 1',
|
||||
planId: 1,
|
||||
isRead: true,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClientNotification,
|
||||
planName: 'Plan 2',
|
||||
planId: 2,
|
||||
isRead: true,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
final result = {
|
||||
'notifications': notifications,
|
||||
'pagination': {
|
||||
'current_page': 1,
|
||||
'last_page': 1,
|
||||
},
|
||||
};
|
||||
|
||||
when(mockNotificationsRepository.getNotifications(page: 1))
|
||||
.thenAnswer((_) async => result);
|
||||
|
||||
await cubit.getNotifications();
|
||||
|
||||
final state = cubit.state;
|
||||
expect(state, isA<NotificationsStateLoaded>());
|
||||
if (state is NotificationsStateLoaded) {
|
||||
expect(state.hasUnreadNotifications, false); // Все прочитаны
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1023,3 +1023,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -171,3 +171,6 @@ void main() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user