332 lines
13 KiB
Dart
332 lines
13 KiB
Dart
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);
|
||
});
|
||
}
|