Files
OnBudget/test/features/transactions/presentation/transaction_form_screen_test.dart
T
2026-05-31 16:40:13 +03:00

332 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/categories/application/categories_controller.dart';
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
import 'package:new_budget/src/features/transactions/application/transactions_controller.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
import 'package:new_budget/src/features/transactions/presentation/screens/transaction_form_screen.dart';
import 'package:new_budget/src/features/transactions/presentation/state/transaction_draft.dart';
import 'package:new_budget/src/features/user/application/active_user_controller.dart';
import 'package:new_budget/src/features/user/domain/entities/user.dart';
// ─── Fakes ───────────────────────────────────────────────────────────────────
class FakeActiveUserController extends ActiveUserController {
@override
Future<User?> build() async =>
User(id: 'test-uid', name: 'Test', createdAt: DateTime(2024));
}
class TxCall {
TxCall({
required this.userId,
required this.accountId,
this.categoryId,
required this.type,
required this.amount,
required this.date,
this.merchant,
this.transferToAccountId,
});
final String userId;
final String accountId;
final String? categoryId;
final TransactionType type;
final int amount;
final DateTime date;
final String? merchant;
final String? transferToAccountId;
}
class FakeTransactionsController extends TransactionsController {
final List<TxCall> calls = [];
@override
AsyncValue<void> build() => const AsyncData(null);
@override
Future<Transaction> createTransaction({
required String userId,
required String accountId,
String? categoryId,
required TransactionType type,
required int amount,
required DateTime date,
String? merchant,
String? extraInfo,
String? transferToAccountId,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
}) async {
calls.add(TxCall(
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: type,
amount: amount,
date: date,
merchant: merchant,
transferToAccountId: transferToAccountId,
));
return Transaction(
id: 'tx-1',
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: type,
amount: amount,
date: date,
merchant: merchant,
transferToAccountId: transferToAccountId,
createdAt: DateTime(2024),
);
}
}
// Заглушка репозитория — не должна вызываться при создании (txId == null).
class _UnusedTransactionRepo implements TransactionRepository {
@override
dynamic noSuchMethod(Invocation i) =>
throw StateError('TransactionRepository should not be called in form create tests');
}
// ─── Helper ──────────────────────────────────────────────────────────────────
Widget _buildForm(FakeTransactionsController fakeCtrl) {
return ProviderScope(
overrides: [
activeUserControllerProvider.overrideWith(() => FakeActiveUserController()),
transactionsControllerProvider.overrideWith(() => fakeCtrl),
transactionRepositoryProvider.overrideWithValue(_UnusedTransactionRepo()),
accountsStreamProvider('test-uid').overrideWith(
(ref) => Stream<List<Account>>.value(const []),
),
// Иначе тянет реальную БД через accountRepository → висящий drift-таймер.
defaultAccountProvider('test-uid').overrideWith(
(ref) => Stream<Account?>.value(null),
),
categoriesByTypeStreamProvider('test-uid', CategoryType.expense).overrideWith(
(ref) => Stream<List<Category>>.value(const []),
),
categoriesByTypeStreamProvider('test-uid', CategoryType.income).overrideWith(
(ref) => Stream<List<Category>>.value(const []),
),
],
child: MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const TransactionFormScreen(),
),
);
}
/// Получить container из дерева после того, как форма отрисовалась.
ProviderContainer _container(WidgetTester tester) =>
ProviderScope.containerOf(tester.element(find.byType(TransactionFormScreen)));
/// Найти текст в снекбаре (не путать с плейсхолдерами в форме).
Finder _snackText(String text) =>
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
/// Кнопка Save лежит в самом низу прокручиваемой формы и в тестовом вьюпорте
/// 800×600 оказывается за пределами экрана. Доскролливаем её перед тапом.
Future<void> _tapSave(WidgetTester tester) async {
await tester.ensureVisible(find.byType(FilledButton));
await tester.tap(find.byType(FilledButton));
await tester.pump();
}
// ─── Tests ───────────────────────────────────────────────────────────────────
void main() {
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
});
late FakeTransactionsController fakeCtrl;
setUp(() => fakeCtrl = FakeTransactionsController());
// ── Валидация суммы ────────────────────────────────────────────────────────
testWidgets('Save с amount=0 показывает встроенную ошибку "Enter an amount"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump(); // activeUserControllerProvider разрешается
// Нажимаем Save не вводя сумму (draft.amountMinor == 0).
await _tapSave(tester);
expect(find.text('Enter an amount'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация счёта ────────────────────────────────────────────────────────
testWidgets('Save без счёта показывает снекбар "Pick an account"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
_container(tester)
.read(transactionDraftControllerProvider(null).notifier)
.setAmount(500);
await tester.pump();
await _tapSave(tester);
expect(_snackText('Pick an account'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация категории (расход) ───────────────────────────────────────────
testWidgets('Расход: Save без категории показывает снекбар "Pick a category"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setAmount(500);
notifier.setAccount('a-1');
// categoryId остаётся null
await tester.pump();
await _tapSave(tester);
expect(_snackText('Pick a category'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация перевода: нет получателя ────────────────────────────────────
testWidgets(
'Перевод: Save без получателя показывает снекбар "Pick a destination account"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.transfer);
notifier.setAmount(500);
notifier.setAccount('a-1');
// transferToAccountId остаётся null
await tester.pump();
await _tapSave(tester);
expect(_snackText('Pick a destination account'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация перевода: одинаковый счёт ───────────────────────────────────
testWidgets(
'Перевод: одинаковый источник и получатель → снекбар "Source and destination must differ"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.transfer);
notifier.setAmount(500);
notifier.setAccount('a-1');
notifier.setTransferToAccount('a-1');
await tester.pump();
await _tapSave(tester);
expect(_snackText('Source and destination must differ'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Happy path: расход ────────────────────────────────────────────────────
testWidgets('Валидный расход: createTransaction вызывается с правильными параметрами',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final testDate = DateTime(2024, 6, 15, 12);
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.expense);
notifier.setAmount(5000);
notifier.setAccount('a-1');
notifier.setCategory('cat-1');
notifier.setDate(testDate);
await tester.pump();
await _tapSave(tester);
expect(fakeCtrl.calls, hasLength(1));
final call = fakeCtrl.calls.first;
expect(call.userId, 'test-uid');
expect(call.accountId, 'a-1');
expect(call.categoryId, 'cat-1');
expect(call.type, TransactionType.expense);
expect(call.amount, 5000);
expect(call.date, testDate);
});
// ── Happy path: перевод ───────────────────────────────────────────────────
testWidgets(
'Валидный перевод: createTransaction вызывается с transferToAccountId, categoryId=null',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.transfer);
notifier.setAmount(3000);
notifier.setAccount('a-1');
notifier.setTransferToAccount('a-2');
await tester.pump();
await _tapSave(tester);
expect(fakeCtrl.calls, hasLength(1));
final call = fakeCtrl.calls.first;
expect(call.type, TransactionType.transfer);
expect(call.transferToAccountId, 'a-2');
expect(call.categoryId, isNull);
});
// ── Note trimming ─────────────────────────────────────────────────────────
testWidgets('Пустая заметка передаётся как null (не как пустая строка)',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setAmount(1000);
notifier.setAccount('a-1');
notifier.setCategory('cat-1');
notifier.setMerchant(' '); // пробелы → null после trim
await tester.pump();
await _tapSave(tester);
expect(fakeCtrl.calls, hasLength(1));
expect(fakeCtrl.calls.first.merchant, isNull);
});
}