Add per-app rules, transfer pairing, self-merchant flag, two-step onboarding
Notification parsing: - Per-app parse rules (parse_rules.packageName; getEnabledForApp, NULL = legacy global) - Transfer pairing: transfer_pair_matcher + transfer_pairing_blocklist table/dao/repo - source_apps.selfMerchant flag (Ozon inbox rework: default account picker, suppress AI category prefill + rule suggestion) - raw_messages.diagnostics dump captured under diagnostic-mode toggle - Inbox card / settings / log UI reworks Onboarding: - Two-step flow (name -> first account); UserSeeder seeds categories only, no accounts Schema bumped to v11; drop obsolete migration + mixed-merchant tests, add new coverage. Add ios/ platform folder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/app_database.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
|
||||
/// Тест миграции v5 → v6 (habit-tracking).
|
||||
///
|
||||
/// Поднимаем актуальную (v6) схему на файле, затем «откатываем» её до v5,
|
||||
/// удаляя новые колонки и проставляя user_version = 5. При повторном открытии
|
||||
/// срабатывает `onUpgrade(5 → 6)`, который должен снова добавить колонки.
|
||||
void main() {
|
||||
late File file;
|
||||
|
||||
setUp(() {
|
||||
final dir = Directory.systemTemp.createTempSync('nb_migration_test');
|
||||
file = File('${dir.path}/test.sqlite');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
final parent = file.parent;
|
||||
if (parent.existsSync()) parent.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('onUpgrade 5 → 6 добавляет obligation/impulse/habitTrackingEnabled',
|
||||
() async {
|
||||
const userId = 'user-1';
|
||||
const accountId = 'account-1';
|
||||
|
||||
// 1. Создаём актуальную схему (v6) и наполняем FK-цепочку.
|
||||
final dbV6 = AppDatabase.forTesting(NativeDatabase(file));
|
||||
await dbV6.usersDao
|
||||
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
|
||||
await dbV6.accountsDao.insertAccount(
|
||||
AccountsTableCompanion.insert(
|
||||
id: accountId,
|
||||
userId: userId,
|
||||
name: 'Основной',
|
||||
),
|
||||
);
|
||||
|
||||
// 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7/v8
|
||||
// (иначе onUpgrade 5→8 попытается создать их повторно).
|
||||
await dbV6.customStatement(
|
||||
'ALTER TABLE transactions DROP COLUMN obligation');
|
||||
await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse');
|
||||
await dbV6.customStatement(
|
||||
'ALTER TABLE settings DROP COLUMN habit_tracking_enabled');
|
||||
await dbV6.customStatement(
|
||||
'ALTER TABLE account_bindings DROP COLUMN is_default');
|
||||
await dbV6.customStatement('DROP TABLE source_apps');
|
||||
await dbV6.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
|
||||
await dbV6.customStatement('PRAGMA user_version = 5');
|
||||
await dbV6.close();
|
||||
|
||||
// 3. Повторное открытие запускает onUpgrade(5 → 6).
|
||||
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
|
||||
addTearDown(dbMigrated.close);
|
||||
|
||||
// 4. Колонки снова доступны: запись с оценками проходит round-trip.
|
||||
final repoRow = await dbMigrated.transactionsDao.findById('tx-1');
|
||||
expect(repoRow, isNull);
|
||||
|
||||
await dbMigrated.transactionsDao.insertTransaction(
|
||||
TransactionsTableCompanion.insert(
|
||||
id: 'tx-1',
|
||||
userId: userId,
|
||||
accountId: accountId,
|
||||
amount: 1000,
|
||||
date: DateTime(2024, 6, 1),
|
||||
obligation: const Value(SpendingObligation.required),
|
||||
impulse: const Value(SpendingImpulse.impulsive),
|
||||
),
|
||||
);
|
||||
final tx = await dbMigrated.transactionsDao.findById('tx-1');
|
||||
expect(tx!.obligation, SpendingObligation.required);
|
||||
expect(tx.impulse, SpendingImpulse.impulsive);
|
||||
|
||||
// settings.habitTrackingEnabled присутствует с дефолтом false.
|
||||
await dbMigrated.settingsDao.upsertSettings(
|
||||
SettingsTableCompanion.insert(userId: userId),
|
||||
);
|
||||
final settings = await dbMigrated.settingsDao.getSettingsByUser(userId);
|
||||
expect(settings!.habitTrackingEnabled, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/app_database.dart';
|
||||
|
||||
/// Тест миграции v6 → v7 (account_bindings.is_default + таблица source_apps).
|
||||
///
|
||||
/// Поднимаем актуальную (v7) схему, «откатываем» до v6 (убираем is_default и
|
||||
/// source_apps, ставим user_version = 6). Повторное открытие запускает
|
||||
/// onUpgrade(6 → 7), который должен восстановить колонку и таблицу.
|
||||
void main() {
|
||||
late File file;
|
||||
|
||||
setUp(() {
|
||||
final dir = Directory.systemTemp.createTempSync('nb_migration_v7_test');
|
||||
file = File('${dir.path}/test.sqlite');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
final parent = file.parent;
|
||||
if (parent.existsSync()) parent.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('onUpgrade 6 → 7 добавляет is_default и таблицу source_apps', () async {
|
||||
const userId = 'user-1';
|
||||
const accountId = 'account-1';
|
||||
|
||||
// 1. Актуальная схема (v7) + FK-цепочка.
|
||||
final dbV7 = AppDatabase.forTesting(NativeDatabase(file));
|
||||
await dbV7.usersDao
|
||||
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
|
||||
await dbV7.accountsDao.insertAccount(
|
||||
AccountsTableCompanion.insert(
|
||||
id: accountId,
|
||||
userId: userId,
|
||||
name: 'Основной',
|
||||
),
|
||||
);
|
||||
|
||||
// 2. «Откатываем» до v6 (включая артефакт v8 — parse_rules.tx_type).
|
||||
await dbV7.customStatement(
|
||||
'ALTER TABLE account_bindings DROP COLUMN is_default');
|
||||
await dbV7.customStatement('DROP TABLE source_apps');
|
||||
await dbV7.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
|
||||
await dbV7.customStatement('PRAGMA user_version = 6');
|
||||
await dbV7.close();
|
||||
|
||||
// 3. Повторное открытие → onUpgrade(6 → 7).
|
||||
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
|
||||
addTearDown(dbMigrated.close);
|
||||
|
||||
// 4a. is_default доступна с дефолтом false (round-trip привязки).
|
||||
await dbMigrated.accountBindingsDao.insert(
|
||||
AccountBindingsTableCompanion.insert(
|
||||
id: 'b-1',
|
||||
userId: userId,
|
||||
accountId: accountId,
|
||||
packageName: const Value('ru.sberbankmobile'),
|
||||
),
|
||||
);
|
||||
final bindings =
|
||||
await dbMigrated.accountBindingsDao.findByPackageName(
|
||||
userId,
|
||||
'ru.sberbankmobile',
|
||||
);
|
||||
expect(bindings, hasLength(1));
|
||||
expect(bindings.first.isDefault, isFalse);
|
||||
|
||||
// 4b. Таблица source_apps существует и принимает строки.
|
||||
await dbMigrated.sourceAppsDao.insert(
|
||||
SourceAppsTableCompanion.insert(
|
||||
id: 's-1',
|
||||
userId: userId,
|
||||
packageName: 'ru.sberbankmobile',
|
||||
),
|
||||
);
|
||||
final enabled =
|
||||
await dbMigrated.sourceAppsDao.watchEnabledPackages(userId).first;
|
||||
expect(enabled, contains('ru.sberbankmobile'));
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/app_database.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
|
||||
/// Тест миграции v7 → v8 (parse_rules.tx_type для gate-проверки
|
||||
/// typeMatchesRule).
|
||||
///
|
||||
/// Поднимаем актуальную (v8) схему, «откатываем» до v7 (убираем tx_type,
|
||||
/// ставим user_version = 7). Повторное открытие запускает onUpgrade(7 → 8),
|
||||
/// который должен восстановить колонку.
|
||||
void main() {
|
||||
late File file;
|
||||
|
||||
setUp(() {
|
||||
final dir = Directory.systemTemp.createTempSync('nb_migration_v8_test');
|
||||
file = File('${dir.path}/test.sqlite');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
final parent = file.parent;
|
||||
if (parent.existsSync()) parent.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('onUpgrade 7 → 8 добавляет parse_rules.tx_type', () async {
|
||||
const userId = 'user-1';
|
||||
|
||||
// 1. Актуальная схема (v8) + FK-цепочка.
|
||||
final dbV8 = AppDatabase.forTesting(NativeDatabase(file));
|
||||
await dbV8.usersDao
|
||||
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
|
||||
|
||||
// 2. «Откатываем» до v7.
|
||||
await dbV8.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
|
||||
await dbV8.customStatement('PRAGMA user_version = 7');
|
||||
await dbV8.close();
|
||||
|
||||
// 3. Повторное открытие → onUpgrade(7 → 8).
|
||||
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
|
||||
addTearDown(dbMigrated.close);
|
||||
|
||||
// 4. Колонка снова доступна: правило с txType проходит round-trip,
|
||||
// легаси-правило без txType читается с null.
|
||||
await dbMigrated.parseRulesDao.insert(
|
||||
ParseRulesTableCompanion.insert(
|
||||
id: 'r-typed',
|
||||
userId: userId,
|
||||
kind: ParseRuleKind.merchantToCategory,
|
||||
pattern: 'LENTA',
|
||||
txType: const Value(TransactionType.expense),
|
||||
),
|
||||
);
|
||||
await dbMigrated.parseRulesDao.insert(
|
||||
ParseRulesTableCompanion.insert(
|
||||
id: 'r-legacy',
|
||||
userId: userId,
|
||||
kind: ParseRuleKind.merchantToCategory,
|
||||
pattern: 'OZON',
|
||||
),
|
||||
);
|
||||
|
||||
final typed = await dbMigrated.parseRulesDao.findById('r-typed');
|
||||
expect(typed!.txType, TransactionType.expense);
|
||||
|
||||
final legacy = await dbMigrated.parseRulesDao.findById('r-legacy');
|
||||
expect(legacy!.txType, isNull);
|
||||
});
|
||||
}
|
||||
@@ -84,15 +84,25 @@ void main() {
|
||||
expect(filtered.map((t) => t.id), unorderedEquals(['2', '3']));
|
||||
});
|
||||
|
||||
test('фильтр по обязательности (мульти) — required + unnecessary', () {
|
||||
test('фильтр по обязательности — одиночный выбор unnecessary', () {
|
||||
final c = makeContainer();
|
||||
addTearDown(c.dispose);
|
||||
c
|
||||
.read(habitObligationFilterProvider.notifier)
|
||||
.select(SpendingObligation.unnecessary);
|
||||
|
||||
final filtered = c.read(habitFilteredTransactionsProvider(userId));
|
||||
expect(filtered.map((t) => t.id), ['2']);
|
||||
});
|
||||
|
||||
test('select(null) сбрасывает фильтр обязательности на «Все»', () {
|
||||
final c = makeContainer();
|
||||
addTearDown(c.dispose);
|
||||
final notifier = c.read(habitObligationFilterProvider.notifier);
|
||||
notifier.toggle(SpendingObligation.required);
|
||||
notifier.toggle(SpendingObligation.unnecessary);
|
||||
notifier.select(SpendingObligation.optional);
|
||||
notifier.select(null);
|
||||
|
||||
final filtered = c.read(habitFilteredTransactionsProvider(userId));
|
||||
expect(filtered.map((t) => t.id), unorderedEquals(['1', '2', '4']));
|
||||
expect(c.read(habitFilteredTransactionsProvider(userId)).length, 5);
|
||||
});
|
||||
|
||||
test('фильтры комбинируются (impulsive AND unnecessary)', () {
|
||||
@@ -103,7 +113,7 @@ void main() {
|
||||
.select(SpendingImpulse.impulsive);
|
||||
c
|
||||
.read(habitObligationFilterProvider.notifier)
|
||||
.toggle(SpendingObligation.unnecessary);
|
||||
.select(SpendingObligation.unnecessary);
|
||||
|
||||
final filtered = c.read(habitFilteredTransactionsProvider(userId));
|
||||
expect(filtered.map((t) => t.id), ['2']);
|
||||
@@ -114,9 +124,51 @@ void main() {
|
||||
addTearDown(c.dispose);
|
||||
c
|
||||
.read(habitObligationFilterProvider.notifier)
|
||||
.toggle(SpendingObligation.required);
|
||||
.select(SpendingObligation.required);
|
||||
|
||||
final filtered = c.read(habitFilteredTransactionsProvider(userId));
|
||||
expect(filtered.map((t) => t.id), unorderedEquals(['1', '4']));
|
||||
});
|
||||
|
||||
test('выбор импульса сбрасывает обязательность=required (каскад)', () {
|
||||
final c = makeContainer();
|
||||
addTearDown(c.dispose);
|
||||
c
|
||||
.read(habitObligationFilterProvider.notifier)
|
||||
.select(SpendingObligation.required);
|
||||
c
|
||||
.read(habitImpulseFilterProvider.notifier)
|
||||
.select(SpendingImpulse.impulsive);
|
||||
|
||||
expect(c.read(habitObligationFilterProvider), isNull);
|
||||
final filtered = c.read(habitFilteredTransactionsProvider(userId));
|
||||
expect(filtered.map((t) => t.id), unorderedEquals(['2', '3']));
|
||||
});
|
||||
|
||||
test('выбор импульса не трогает обязательность ≠ required', () {
|
||||
final c = makeContainer();
|
||||
addTearDown(c.dispose);
|
||||
c
|
||||
.read(habitObligationFilterProvider.notifier)
|
||||
.select(SpendingObligation.optional);
|
||||
c
|
||||
.read(habitImpulseFilterProvider.notifier)
|
||||
.select(SpendingImpulse.impulsive);
|
||||
|
||||
expect(c.read(habitObligationFilterProvider), SpendingObligation.optional);
|
||||
});
|
||||
|
||||
test('habitSumByObligation учитывает активный импульс-фильтр', () {
|
||||
final c = makeContainer();
|
||||
addTearDown(c.dispose);
|
||||
c
|
||||
.read(habitImpulseFilterProvider.notifier)
|
||||
.select(SpendingImpulse.impulsive);
|
||||
|
||||
final map = c.read(habitSumByObligationProvider(userId));
|
||||
expect(map[SpendingObligation.unnecessary], 2000);
|
||||
expect(map[SpendingObligation.optional], 500);
|
||||
expect(map.containsKey(SpendingObligation.required), isFalse);
|
||||
expect(map.containsKey(null), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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/analytics/application/habit_analysis_providers.dart';
|
||||
import 'package:new_budget/src/features/analytics/presentation/screens/habit_analysis_screen.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/domain/entities/transaction.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 / данные ──────────────────────────────────────────────────────────
|
||||
|
||||
const _userId = 'test-uid';
|
||||
|
||||
class FakeActiveUserController extends ActiveUserController {
|
||||
@override
|
||||
Future<User?> build() async =>
|
||||
User(id: _userId, name: 'Test', createdAt: DateTime(2024));
|
||||
}
|
||||
|
||||
Transaction _tx(
|
||||
String id,
|
||||
int amount, {
|
||||
SpendingObligation? obligation,
|
||||
SpendingImpulse? impulse,
|
||||
}) =>
|
||||
Transaction(
|
||||
id: id,
|
||||
userId: _userId,
|
||||
accountId: 'a',
|
||||
type: TransactionType.expense,
|
||||
amount: amount,
|
||||
date: DateTime(2026, 6, 10),
|
||||
obligation: obligation,
|
||||
impulse: impulse,
|
||||
createdAt: DateTime(2026, 6, 10),
|
||||
);
|
||||
|
||||
final _txs = [
|
||||
_tx('1', 1000, obligation: SpendingObligation.required),
|
||||
_tx('2', 2000,
|
||||
obligation: SpendingObligation.unnecessary,
|
||||
impulse: SpendingImpulse.impulsive),
|
||||
_tx('3', 500,
|
||||
obligation: SpendingObligation.optional,
|
||||
impulse: SpendingImpulse.considered),
|
||||
];
|
||||
|
||||
Widget _buildScreen() => ProviderScope(
|
||||
overrides: [
|
||||
activeUserControllerProvider
|
||||
.overrideWith(() => FakeActiveUserController()),
|
||||
habitMonthTransactionsProvider(_userId).overrideWithValue(_txs),
|
||||
categoriesStreamProvider(_userId)
|
||||
.overrideWith((ref) => Stream<List<Category>>.value(const [])),
|
||||
accountsStreamProvider(_userId)
|
||||
.overrideWith((ref) => Stream<List<Account>>.value(const [])),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const HabitAnalysisScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
ProviderContainer _container(WidgetTester tester) =>
|
||||
ProviderScope.containerOf(tester.element(find.byType(HabitAnalysisScreen)));
|
||||
|
||||
/// Сегмент «Impulse» в строке фильтров (первый — пилюли в списке ниже).
|
||||
Finder _impulseSegment() => find.text('Impulse').first;
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
GoogleFonts.config.allowRuntimeFetching = false;
|
||||
});
|
||||
|
||||
testWidgets('по умолчанию: оба «All» активны, чип «Necessary» виден',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pump(); // activeUserControllerProvider разрешается
|
||||
|
||||
// «All» в обеих строках фильтров.
|
||||
expect(find.text('All'), findsNWidgets(2));
|
||||
// Чип обязательности + пилюля tx1 в списке.
|
||||
expect(find.text('Necessary'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('выбор «Impulse» скрывает чип «Necessary»', (tester) async {
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(_impulseSegment());
|
||||
// AnimatedSize — доигрываем анимацию.
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Чип скрыт, tx1 отфильтрована — «Necessary» нет нигде.
|
||||
expect(find.text('Necessary'), findsNothing);
|
||||
|
||||
// Возврат на «All» импульсивности возвращает чип.
|
||||
await tester.tap(find.text('All').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Necessary'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('выбор «Impulse» сбрасывает выбранную «Necessary» (каскад)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Necessary').first);
|
||||
await tester.pumpAndSettle();
|
||||
final container = _container(tester);
|
||||
expect(
|
||||
container.read(habitObligationFilterProvider), SpendingObligation.required);
|
||||
|
||||
await tester.tap(_impulseSegment());
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(habitObligationFilterProvider), isNull);
|
||||
});
|
||||
|
||||
testWidgets('повторный тап по чипу обязательности снимает фильтр',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Unnecessary').first);
|
||||
await tester.pumpAndSettle();
|
||||
final container = _container(tester);
|
||||
expect(container.read(habitObligationFilterProvider),
|
||||
SpendingObligation.unnecessary);
|
||||
|
||||
await tester.tap(find.text('Unnecessary').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(habitObligationFilterProvider), isNull);
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/account_binding.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
|
||||
@@ -130,8 +129,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
|
||||
final List<Map<String, Object?>> created = [];
|
||||
final List<String> deletedIds = [];
|
||||
|
||||
/// Правила, которые вернёт [getByUser] (для проверки удаления существующего
|
||||
/// merchant→category правила при пометке мерчанта как mixed).
|
||||
/// Правила, которые вернёт [getByUser].
|
||||
List<ParseRule> existing = const [];
|
||||
|
||||
@override
|
||||
@@ -140,9 +138,17 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
|
||||
@override
|
||||
Future<void> deleteById(String id) async => deletedIds.add(id);
|
||||
|
||||
@override
|
||||
Future<List<ParseRule>> getEnabledForApp(
|
||||
String userId,
|
||||
String packageName,
|
||||
) async =>
|
||||
existing;
|
||||
|
||||
@override
|
||||
Future<ParseRule> create({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
required ParseRuleKind kind,
|
||||
required MatchMode matchMode,
|
||||
required String pattern,
|
||||
@@ -153,6 +159,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
|
||||
String? accountId,
|
||||
}) async {
|
||||
created.add({
|
||||
'packageName': packageName,
|
||||
'kind': kind,
|
||||
'matchMode': matchMode,
|
||||
'pattern': pattern,
|
||||
@@ -163,6 +170,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
|
||||
return ParseRule(
|
||||
id: 'rule1',
|
||||
userId: userId,
|
||||
packageName: packageName,
|
||||
kind: kind,
|
||||
matchMode: matchMode,
|
||||
pattern: pattern,
|
||||
@@ -296,6 +304,8 @@ void main() {
|
||||
expect(rulesRepo.created, hasLength(1));
|
||||
expect(rulesRepo.created.single['kind'], ParseRuleKind.merchantToCategory);
|
||||
expect(rulesRepo.created.single['categoryId'], 'cat1');
|
||||
// Правило привязывается к приложению-источнику сообщения (per-app scope).
|
||||
expect(rulesRepo.created.single['packageName'], 'ru.sberbankmobile');
|
||||
// Тип операции фиксируется в правиле — gate-проверка typeMatchesRule.
|
||||
expect(rulesRepo.created.single['txType'], TransactionType.expense);
|
||||
|
||||
@@ -352,54 +362,14 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('markMerchantMixed', () {
|
||||
test('creates mixedMerchant rule, drops candidate, re-caches without '
|
||||
'suggestion', () async {
|
||||
await controller().markMerchantMixed(
|
||||
userId: _userId,
|
||||
message: _message(),
|
||||
bundle: DraftBundle(draft: _draft()),
|
||||
merchantCanonical: 'PYATEROCHKA',
|
||||
);
|
||||
group('markApplied', () {
|
||||
test('links message to a transaction saved via the full form', () async {
|
||||
await controller().markApplied(_message(), 'tx-9');
|
||||
|
||||
expect(rulesRepo.created, hasLength(1));
|
||||
expect(rulesRepo.created.single['kind'], ParseRuleKind.mixedMerchant);
|
||||
expect(rulesRepo.created.single['pattern'], 'PYATEROCHKA');
|
||||
|
||||
// Кандидат снят, чтобы не предлагать правило снова.
|
||||
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
|
||||
|
||||
// draftJson переписан без suggestion → карточка переключится на confirm.
|
||||
expect(rawRepo.afterParse, hasLength(1));
|
||||
final draftJson = rawRepo.afterParse.single['draftJson'] as String;
|
||||
expect(decodeDraftBundle(draftJson)?.suggestion, isNull);
|
||||
|
||||
// Нет транзакции — пользователь категоризует вручную.
|
||||
expect(rawRepo.linked, contains(('msg1', 'tx-9')));
|
||||
// Только линковка: ни транзакций, ни правил контроллер не создаёт.
|
||||
expect(txRepo.created, isEmpty);
|
||||
});
|
||||
|
||||
test('removes an existing merchant→category rule for the merchant',
|
||||
() async {
|
||||
rulesRepo.existing = [
|
||||
ParseRule(
|
||||
id: 'old-rule',
|
||||
userId: _userId,
|
||||
kind: ParseRuleKind.merchantToCategory,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: 'PYATEROCHKA',
|
||||
categoryId: 'cat1',
|
||||
createdAt: _now,
|
||||
),
|
||||
];
|
||||
|
||||
await controller().markMerchantMixed(
|
||||
userId: _userId,
|
||||
message: _message(),
|
||||
bundle: DraftBundle(draft: _draft()),
|
||||
merchantCanonical: 'PYATEROCHKA',
|
||||
);
|
||||
|
||||
expect(rulesRepo.deletedIds, contains('old-rule'));
|
||||
expect(rulesRepo.created, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:new_budget/src/core/database/app_database.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/core/providers/database_provider.dart';
|
||||
import 'package:new_budget/src/features/accounts/application/account_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
|
||||
|
||||
/// Сквозные тесты per-app скоупа правил (parse_rules.packageName): правило,
|
||||
/// созданное для одного приложения-источника, не должно срабатывать на
|
||||
/// сообщениях другого. AI замокан (без сети) и отдаёт фиксированный draft-JSON.
|
||||
|
||||
const _userId = 'u1';
|
||||
const _accountId = 'acc-default';
|
||||
const _bankA = 'ru.sberbankmobile';
|
||||
const _bankB = 'com.idamob.tinkoff.android';
|
||||
|
||||
AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
|
||||
final mock = MockClient((req) async {
|
||||
final content = jsonEncode({
|
||||
'type': 'expense',
|
||||
'kind': 'purchase',
|
||||
'amount': amount,
|
||||
'currency': 'RUB',
|
||||
'merchantRaw': merchantRaw,
|
||||
});
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': content},
|
||||
}
|
||||
],
|
||||
'usage': {'total_tokens': 42},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
|
||||
}
|
||||
|
||||
Future<void> _seed(AppDatabase db) async {
|
||||
await db.usersDao.insertUser(
|
||||
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||||
);
|
||||
await db.accountsDao.insertAccount(
|
||||
AccountsTableCompanion.insert(id: _accountId, userId: _userId, name: 'Main'),
|
||||
);
|
||||
// Оба банка в allowlist — до правил доходят сообщения обоих.
|
||||
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||
id: 'src-a',
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
));
|
||||
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||
id: 'src-b',
|
||||
userId: _userId,
|
||||
packageName: _bankB,
|
||||
));
|
||||
}
|
||||
|
||||
void _activateWorker(ProviderContainer c) {
|
||||
c.read(parsingWorkerProvider(_userId));
|
||||
}
|
||||
|
||||
Future<RawMessage> _waitTerminal(
|
||||
RawMessagesRepository repo,
|
||||
String id, {
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
const transient = {
|
||||
RawMessageStatus.pending,
|
||||
RawMessageStatus.parsing,
|
||||
RawMessageStatus.pendingAi,
|
||||
};
|
||||
final completer = Completer<RawMessage>();
|
||||
late final StreamSubscription<List<RawMessage>> sub;
|
||||
sub = repo.watchAll(_userId).listen((list) {
|
||||
for (final m in list) {
|
||||
if (m.id == id && !transient.contains(m.status)) {
|
||||
if (!completer.isCompleted) completer.complete(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await completer.future.timeout(timeout);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late ProviderContainer container;
|
||||
late RawMessagesRepository repo;
|
||||
|
||||
Future<void> bootstrap() async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
await _seed(db);
|
||||
container = ProviderContainer(overrides: [
|
||||
appDatabaseProvider.overrideWithValue(db),
|
||||
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||||
aiParserProvider.overrideWith(
|
||||
(ref) async => _fakeAiParser(merchantRaw: 'LENTA', amount: 1500)),
|
||||
]);
|
||||
repo = container.read(rawMessagesRepositoryProvider);
|
||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||
await container.read(parsingSettingsControllerProvider.future);
|
||||
await settings.setAiConsent(true);
|
||||
await container
|
||||
.read(accountRepositoryProvider)
|
||||
.setDefault(_accountId, _userId);
|
||||
}
|
||||
|
||||
tearDown(() async {
|
||||
container.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('ignore-правило bankA: bankA → ignored, то же тело от bankB → inbox',
|
||||
() async {
|
||||
await bootstrap();
|
||||
await container.read(parseRulesRepositoryProvider).create(
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
kind: ParseRuleKind.ignore,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: 'Доставлен заказ',
|
||||
);
|
||||
_activateWorker(container);
|
||||
|
||||
const body = 'Доставлен заказ №123 на сумму 1500 руб';
|
||||
final fromA = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
body: body,
|
||||
receivedAt: DateTime(2026, 7, 1, 12),
|
||||
);
|
||||
expect((await _waitTerminal(repo, fromA.id)).status,
|
||||
RawMessageStatus.ignored,
|
||||
reason: 'ignore-правило действует в своём приложении (pre-AI путь)');
|
||||
|
||||
final fromB = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankB,
|
||||
body: body,
|
||||
receivedAt: DateTime(2026, 7, 1, 13),
|
||||
);
|
||||
expect(
|
||||
(await _waitTerminal(repo, fromB.id)).status, RawMessageStatus.inbox,
|
||||
reason: 'ignore-правило bankA не должно протекать в bankB');
|
||||
});
|
||||
|
||||
test('merchant-правило bankA: тот же мерчант от bankB → inbox с suggestion',
|
||||
() async {
|
||||
await bootstrap();
|
||||
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
|
||||
id: 'cat-1',
|
||||
userId: _userId,
|
||||
name: 'Продукты',
|
||||
));
|
||||
await container.read(parseRulesRepositoryProvider).create(
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
kind: ParseRuleKind.merchantToCategory,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: 'LENTA',
|
||||
txType: TransactionType.expense,
|
||||
categoryId: 'cat-1',
|
||||
);
|
||||
_activateWorker(container);
|
||||
|
||||
final fromB = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankB,
|
||||
body: 'Payment of 1500 RUB at LENTA',
|
||||
receivedAt: DateTime(2026, 7, 1, 12),
|
||||
);
|
||||
|
||||
final msg = await _waitTerminal(repo, fromB.id);
|
||||
expect(msg.status, RawMessageStatus.inbox,
|
||||
reason: 'правило bankA не видно в bankB → нет auto-apply');
|
||||
|
||||
// Мерчант «незнакомый» в скоупе bankB → pipeline предлагает создать правило.
|
||||
final bundle = decodeDraftBundle(msg.draftJson);
|
||||
expect(bundle, isNotNull);
|
||||
expect(bundle!.suggestion, isNotNull,
|
||||
reason: 'для незнакомого в этом приложении мерчанта нужна suggestion');
|
||||
expect(bundle.suggestion!.merchantCanonical, 'LENTA');
|
||||
|
||||
// Транзакция не создана — правило чужого приложения не применилось.
|
||||
final txns = await db.select(db.transactionsTable).get();
|
||||
expect(txns, isEmpty);
|
||||
});
|
||||
}
|
||||
+55
-27
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -20,16 +21,21 @@ import 'package:new_budget/src/features/notification_parsing/domain/entities/raw
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
|
||||
|
||||
/// Сквозные тесты подавления предложения правила для mixed-мерчанта (§9.1):
|
||||
/// знакомый мерчант без маркера → Inbox с suggestion; с маркером
|
||||
/// `mixedMerchant` → Inbox без suggestion (карточка покажет «подтвердить
|
||||
/// разово»). AI замокан (без сети) и отдаёт фиксированный draft-JSON.
|
||||
/// Сквозные тесты источников с флагом «мерчант — само приложение»
|
||||
/// (source_apps.selfMerchant): без флага → Inbox с предложением правила;
|
||||
/// с флагом → Inbox без suggestion и без AI-подсказки категории (карточка
|
||||
/// покажет ручной выбор категории). AI замокан (без сети) и отдаёт
|
||||
/// фиксированный draft-JSON.
|
||||
|
||||
const _userId = 'u1';
|
||||
const _accountId = 'acc-default';
|
||||
const _bank = 'ru.sberbankmobile';
|
||||
|
||||
AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
|
||||
AiParser _fakeAiParser({
|
||||
required String merchantRaw,
|
||||
required num amount,
|
||||
String? categorySuggestion,
|
||||
}) {
|
||||
final mock = MockClient((req) async {
|
||||
final content = jsonEncode({
|
||||
'type': 'expense',
|
||||
@@ -37,6 +43,7 @@ AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
|
||||
'amount': amount,
|
||||
'currency': 'RUB',
|
||||
'merchantRaw': merchantRaw,
|
||||
'categorySuggestion': ?categorySuggestion,
|
||||
});
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
@@ -54,7 +61,7 @@ AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
|
||||
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
|
||||
}
|
||||
|
||||
Future<void> _seed(AppDatabase db) async {
|
||||
Future<void> _seed(AppDatabase db, {bool selfMerchant = false}) async {
|
||||
await db.usersDao.insertUser(
|
||||
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||||
);
|
||||
@@ -63,12 +70,15 @@ Future<void> _seed(AppDatabase db) async {
|
||||
);
|
||||
await db.sourceAppsDao.insert(
|
||||
SourceAppsTableCompanion.insert(
|
||||
id: 'src-1', userId: _userId, packageName: _bank),
|
||||
id: 'src-1',
|
||||
userId: _userId,
|
||||
packageName: _bank,
|
||||
selfMerchant: Value(selfMerchant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _activateWorker(ProviderContainer c) {
|
||||
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
|
||||
c.read(parsingWorkerProvider(_userId));
|
||||
}
|
||||
|
||||
@@ -104,14 +114,19 @@ void main() {
|
||||
late ProviderContainer container;
|
||||
late RawMessagesRepository repo;
|
||||
|
||||
Future<void> bootstrap() async {
|
||||
Future<void> bootstrap({
|
||||
bool selfMerchant = false,
|
||||
String? categorySuggestion,
|
||||
}) async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
await _seed(db);
|
||||
await _seed(db, selfMerchant: selfMerchant);
|
||||
container = ProviderContainer(overrides: [
|
||||
appDatabaseProvider.overrideWithValue(db),
|
||||
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||||
aiParserProvider.overrideWith(
|
||||
(ref) async => _fakeAiParser(merchantRaw: 'OZON', amount: 1500)),
|
||||
aiParserProvider.overrideWith((ref) async => _fakeAiParser(
|
||||
merchantRaw: 'OZON',
|
||||
amount: 1500,
|
||||
categorySuggestion: categorySuggestion)),
|
||||
]);
|
||||
repo = container.read(rawMessagesRepositoryProvider);
|
||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||
@@ -127,8 +142,7 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('знакомый мерчант без mixed-маркера → Inbox с предложением правила',
|
||||
() async {
|
||||
test('источник без флага → Inbox с предложением правила', () async {
|
||||
await bootstrap();
|
||||
_activateWorker(container);
|
||||
|
||||
@@ -145,20 +159,12 @@ void main() {
|
||||
final bundle = decodeDraftBundle(msg.draftJson);
|
||||
expect(bundle, isNotNull);
|
||||
expect(bundle!.suggestion, isNotNull,
|
||||
reason: 'без маркера должно быть предложение правила');
|
||||
reason: 'без флага должно быть предложение правила');
|
||||
expect(bundle.suggestion!.merchantCanonical, 'OZON');
|
||||
});
|
||||
|
||||
test('mixedMerchant-маркер → Inbox без предложения правила (suggestion null)',
|
||||
() async {
|
||||
await bootstrap();
|
||||
// Пользователь пометил мерчанта как «категории различаются».
|
||||
await container.read(parseRulesRepositoryProvider).create(
|
||||
userId: _userId,
|
||||
kind: ParseRuleKind.mixedMerchant,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: 'OZON',
|
||||
);
|
||||
test('selfMerchant-источник → Inbox без предложения правила', () async {
|
||||
await bootstrap(selfMerchant: true);
|
||||
_activateWorker(container);
|
||||
|
||||
final inserted = await repo.insertIncoming(
|
||||
@@ -169,15 +175,37 @@ void main() {
|
||||
);
|
||||
|
||||
final msg = await _waitTerminal(repo, inserted.id);
|
||||
// Маркер не применяет действий → не авто-применяется, остаётся в Inbox.
|
||||
// Правила нет → не авто-применяется, остаётся в Inbox.
|
||||
expect(msg.status, RawMessageStatus.inbox);
|
||||
|
||||
final bundle = decodeDraftBundle(msg.draftJson);
|
||||
expect(bundle, isNotNull);
|
||||
expect(bundle!.suggestion, isNull,
|
||||
reason: 'маркер mixedMerchant должен подавлять предложение правила');
|
||||
reason: 'флаг selfMerchant должен подавлять предложение правила');
|
||||
// Транзакция не создаётся — пользователь категоризует вручную.
|
||||
final txns = await db.select(db.transactionsTable).get();
|
||||
expect(txns, isEmpty);
|
||||
});
|
||||
|
||||
test('selfMerchant-источник глушит AI-подсказку категории в бандле',
|
||||
() async {
|
||||
await bootstrap(selfMerchant: true, categorySuggestion: 'Продукты');
|
||||
_activateWorker(container);
|
||||
|
||||
final inserted = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bank,
|
||||
body: 'Покупка 1500 RUB OZON',
|
||||
receivedAt: DateTime(2026, 5, 31, 12),
|
||||
);
|
||||
|
||||
final msg = await _waitTerminal(repo, inserted.id);
|
||||
expect(msg.status, RawMessageStatus.inbox);
|
||||
|
||||
final bundle = decodeDraftBundle(msg.draftJson);
|
||||
expect(bundle, isNotNull);
|
||||
expect(bundle!.draft.categorySuggestion, isNull,
|
||||
reason: 'AI-подсказка категории должна глушиться для selfMerchant');
|
||||
expect(bundle.suggestion, isNull);
|
||||
});
|
||||
}
|
||||
@@ -73,7 +73,6 @@ Future<void> _seed(AppDatabase db) async {
|
||||
}
|
||||
|
||||
void _activateWorker(ProviderContainer c) {
|
||||
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
|
||||
c.read(parsingWorkerProvider(_userId));
|
||||
}
|
||||
|
||||
@@ -177,6 +176,7 @@ void main() {
|
||||
// Ignore-правило: «Доставлен заказ» пропускать.
|
||||
await container.read(parseRulesRepositoryProvider).create(
|
||||
userId: _userId,
|
||||
packageName: _bank,
|
||||
kind: ParseRuleKind.ignore,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: 'Доставлен заказ',
|
||||
@@ -229,6 +229,7 @@ void main() {
|
||||
));
|
||||
await container.read(parseRulesRepositoryProvider).create(
|
||||
userId: _userId,
|
||||
packageName: _bank,
|
||||
kind: ParseRuleKind.merchantToCategory,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: 'LENTA',
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:new_budget/src/core/database/app_database.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/core/providers/database_provider.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/parsing_pipeline.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/transfer_pair_matcher.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
|
||||
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
|
||||
|
||||
/// Сквозные тесты склейки переводов (transfer pairing): waitingPair → sweep →
|
||||
/// merged-карточка / доклейка / релиз по таймауту / расклейка. AI замокан:
|
||||
/// по телу сообщения возвращает transfer_out («Вы перевели …») либо
|
||||
/// transfer_in («Пополнение …»).
|
||||
|
||||
const _userId = 'u1';
|
||||
const _accA = 'acc-a';
|
||||
const _accB = 'acc-b';
|
||||
const _bankA = 'ru.sberbankmobile';
|
||||
const _bankB = 'com.idamob.tinkoff.android';
|
||||
|
||||
const _outBody = 'Вы перевели 5000 ₽ на счёт в другом банке';
|
||||
const _inBody = 'Пополнение 5000 ₽ переводом из банка';
|
||||
const _amountMinor = 500000;
|
||||
|
||||
AiParser _pairAiParser() {
|
||||
final mock = MockClient((req) async {
|
||||
// Матчим ПОЛНЫЙ текст уведомления: system-промпт сам содержит слова
|
||||
// «перевели»/«Пополнение» (правила выбора kind), по подстроке нельзя.
|
||||
final isOut = req.body.contains(_outBody);
|
||||
final content = jsonEncode({
|
||||
'type': isOut ? 'expense' : 'income',
|
||||
'kind': isOut ? 'transfer_out' : 'transfer_in',
|
||||
'amount': 5000,
|
||||
'currency': 'RUB',
|
||||
});
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': content},
|
||||
}
|
||||
],
|
||||
'usage': {'total_tokens': 42},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
|
||||
}
|
||||
|
||||
Future<void> _seed(AppDatabase db) async {
|
||||
await db.usersDao.insertUser(
|
||||
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||||
);
|
||||
await db.accountsDao.insertAccount(
|
||||
AccountsTableCompanion.insert(id: _accA, userId: _userId, name: 'A'),
|
||||
);
|
||||
await db.accountsDao.insertAccount(
|
||||
AccountsTableCompanion.insert(id: _accB, userId: _userId, name: 'B'),
|
||||
);
|
||||
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||
id: 'src-a',
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
));
|
||||
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||||
id: 'src-b',
|
||||
userId: _userId,
|
||||
packageName: _bankB,
|
||||
));
|
||||
}
|
||||
|
||||
void _activateWorker(ProviderContainer c) {
|
||||
c.read(parsingWorkerProvider(_userId));
|
||||
}
|
||||
|
||||
/// Ждёт, пока сообщение [id] не пройдёт [test] (по стриму watchAll).
|
||||
Future<RawMessage> _waitFor(
|
||||
RawMessagesRepository repo,
|
||||
String id,
|
||||
bool Function(RawMessage) test, {
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final completer = Completer<RawMessage>();
|
||||
late final StreamSubscription<List<RawMessage>> sub;
|
||||
sub = repo.watchAll(_userId).listen((list) {
|
||||
for (final m in list) {
|
||||
if (m.id == id && test(m)) {
|
||||
if (!completer.isCompleted) completer.complete(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await completer.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () async {
|
||||
final m = await repo.findById(id);
|
||||
throw TimeoutException(
|
||||
'message $id stuck: status=${m?.status}, paired=${m?.pairedWithId}');
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late ProviderContainer container;
|
||||
late RawMessagesRepository repo;
|
||||
|
||||
Future<void> bootstrap() async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
await _seed(db);
|
||||
container = ProviderContainer(overrides: [
|
||||
appDatabaseProvider.overrideWithValue(db),
|
||||
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||||
aiParserProvider.overrideWith((ref) async => _pairAiParser()),
|
||||
]);
|
||||
repo = container.read(rawMessagesRepositoryProvider);
|
||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||
await container.read(parsingSettingsControllerProvider.future);
|
||||
await settings.setAiConsent(true);
|
||||
// Однозначные привязки: bankA → accA, bankB → accB (resolver #3, trusted).
|
||||
final bindings = container.read(accountBindingsRepositoryProvider);
|
||||
await bindings.create(
|
||||
userId: _userId, packageName: _bankA, accountId: _accA);
|
||||
await bindings.create(
|
||||
userId: _userId, packageName: _bankB, accountId: _accB);
|
||||
}
|
||||
|
||||
tearDown(() async {
|
||||
container.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Future<void> forceDeadlinePast(String id) => (db.update(db.rawMessagesTable)
|
||||
..where((t) => t.id.equals(id)))
|
||||
.write(RawMessagesTableCompanion(
|
||||
pairDeadline: Value(DateTime.now().subtract(const Duration(minutes: 1))),
|
||||
));
|
||||
|
||||
/// out (bankA) + in (bankB) в окне → склейка воркером.
|
||||
Future<(RawMessage out, RawMessage inn)> mergePairFlow() async {
|
||||
final t0 = DateTime(2026, 7, 1, 12);
|
||||
final out = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
body: _outBody,
|
||||
receivedAt: t0,
|
||||
);
|
||||
final inn = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankB,
|
||||
body: _inBody,
|
||||
receivedAt: t0.add(const Duration(minutes: 1)),
|
||||
);
|
||||
final mergedOut = await _waitFor(
|
||||
repo,
|
||||
out.id,
|
||||
(m) => m.status == RawMessageStatus.inbox && m.pairedWithId == inn.id,
|
||||
);
|
||||
final pairedIn = await _waitFor(
|
||||
repo,
|
||||
inn.id,
|
||||
(m) => m.status == RawMessageStatus.paired,
|
||||
);
|
||||
return (mergedOut, pairedIn);
|
||||
}
|
||||
|
||||
test('склейка: out+in в окне → primary inbox (merged draft), secondary paired',
|
||||
() async {
|
||||
await bootstrap();
|
||||
_activateWorker(container);
|
||||
|
||||
final (out, inn) = await mergePairFlow();
|
||||
|
||||
final bundle = decodeDraftBundle(out.draftJson)!;
|
||||
expect(bundle.pairedRawMessageId, inn.id);
|
||||
expect(bundle.draft.type, TransactionType.transfer);
|
||||
expect(bundle.draft.accountId, _accA);
|
||||
expect(bundle.draft.transferToAccountId, _accB);
|
||||
expect(bundle.draft.categoryId, isNull);
|
||||
expect(bundle.preMerge?['type'], 'expense',
|
||||
reason: 'снапшот исходного типа для расклейки');
|
||||
|
||||
expect(inn.pairedWithId, out.id);
|
||||
final innBundle = decodeDraftBundle(inn.draftJson)!;
|
||||
expect(innBundle.draft.kind, TxKind.transferIn,
|
||||
reason: 'одиночный draft secondary не перезаписывается');
|
||||
expect(innBundle.pairedRawMessageId, isNull);
|
||||
|
||||
// Транзакций нет — склеенная пара всегда ждёт подтверждения.
|
||||
expect(await db.select(db.transactionsTable).get(), isEmpty);
|
||||
});
|
||||
|
||||
test('confirmPair → одна transfer-транзакция, обе половинки applied',
|
||||
() async {
|
||||
await bootstrap();
|
||||
_activateWorker(container);
|
||||
|
||||
final (out, _) = await mergePairFlow();
|
||||
final bundle = decodeDraftBundle(out.draftJson)!;
|
||||
|
||||
await container.read(inboxControllerProvider.notifier).confirmPair(
|
||||
userId: _userId,
|
||||
message: out,
|
||||
draft: bundle.draft,
|
||||
secondaryId: bundle.pairedRawMessageId!,
|
||||
fromAccountId: _accA,
|
||||
toAccountId: _accB,
|
||||
);
|
||||
|
||||
final txns = await db.select(db.transactionsTable).get();
|
||||
expect(txns, hasLength(1));
|
||||
expect(txns.single.type, TransactionType.transfer);
|
||||
expect(txns.single.accountId, _accA);
|
||||
expect(txns.single.transferToAccountId, _accB);
|
||||
expect(txns.single.amount, _amountMinor);
|
||||
expect(txns.single.rawMessageId, out.id);
|
||||
|
||||
final outAfter = await repo.findById(out.id);
|
||||
final innAfter = await repo.findById(bundle.pairedRawMessageId!);
|
||||
expect(outAfter!.status, RawMessageStatus.applied);
|
||||
expect(innAfter!.status, RawMessageStatus.applied);
|
||||
expect(outAfter.transactionId, txns.single.id);
|
||||
expect(innAfter.transactionId, txns.single.id);
|
||||
});
|
||||
|
||||
test('таймаут: релиз в Inbox одиночным черновиком, draft не затёрт',
|
||||
() async {
|
||||
await bootstrap();
|
||||
_activateWorker(container);
|
||||
|
||||
final out = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
body: _outBody,
|
||||
receivedAt: DateTime(2026, 7, 1, 12),
|
||||
);
|
||||
final waiting = await _waitFor(
|
||||
repo, out.id, (m) => m.status == RawMessageStatus.waitingPair);
|
||||
expect(waiting.pairDeadline, isNotNull);
|
||||
|
||||
await forceDeadlinePast(out.id);
|
||||
await container.read(parsingPipelineProvider).sweepPairs(_userId);
|
||||
|
||||
final released = await _waitFor(
|
||||
repo, out.id, (m) => m.status == RawMessageStatus.inbox);
|
||||
final bundle = decodeDraftBundle(released.draftJson)!;
|
||||
expect(bundle.draft.kind, TxKind.transferOut);
|
||||
expect(bundle.draft.amount, _amountMinor);
|
||||
expect(bundle.pairedRawMessageId, isNull);
|
||||
expect(released.confidenceAmount, isNotNull,
|
||||
reason: 'релиз не должен затирать confidence-оценки');
|
||||
});
|
||||
|
||||
test('inbox-контрпартнёр: релизнутая половинка склеивается со второй',
|
||||
() async {
|
||||
await bootstrap();
|
||||
_activateWorker(container);
|
||||
|
||||
final t0 = DateTime(2026, 7, 1, 12);
|
||||
final out = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
body: _outBody,
|
||||
receivedAt: t0,
|
||||
);
|
||||
await _waitFor(repo, out.id, (m) => m.status == RawMessageStatus.waitingPair);
|
||||
await forceDeadlinePast(out.id);
|
||||
await container.read(parsingPipelineProvider).sweepPairs(_userId);
|
||||
await _waitFor(repo, out.id, (m) => m.status == RawMessageStatus.inbox);
|
||||
|
||||
// Вторая половинка приходит в окне receivedAt первой (которая уже в Inbox).
|
||||
final inn = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankB,
|
||||
body: _inBody,
|
||||
receivedAt: t0.add(const Duration(minutes: 4)),
|
||||
);
|
||||
|
||||
final mergedOut = await _waitFor(
|
||||
repo,
|
||||
out.id,
|
||||
(m) => m.status == RawMessageStatus.inbox && m.pairedWithId == inn.id,
|
||||
);
|
||||
final pairedIn = await _waitFor(
|
||||
repo, inn.id, (m) => m.status == RawMessageStatus.paired);
|
||||
expect(pairedIn.pairedWithId, out.id);
|
||||
final bundle = decodeDraftBundle(mergedOut.draftJson)!;
|
||||
expect(bundle.draft.type, TransactionType.transfer);
|
||||
expect(bundle.draft.transferToAccountId, _accB);
|
||||
});
|
||||
|
||||
test('доклейка: applied-контрпартнёр конвертируется в transfer + откат',
|
||||
() async {
|
||||
await bootstrap();
|
||||
final settings = container.read(parsingSettingsControllerProvider.notifier);
|
||||
// Первая половинка проходит обычным путём (пейринг выключен) и
|
||||
// подтверждается пользователем как обычный расход.
|
||||
await settings.setTransferPairingEnabled(false);
|
||||
_activateWorker(container);
|
||||
|
||||
final t0 = DateTime(2026, 7, 1, 12);
|
||||
final out = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankA,
|
||||
body: _outBody,
|
||||
receivedAt: t0,
|
||||
);
|
||||
final outInbox = await _waitFor(
|
||||
repo, out.id, (m) => m.status == RawMessageStatus.inbox);
|
||||
final outBundle = decodeDraftBundle(outInbox.draftJson)!;
|
||||
await container.read(inboxControllerProvider.notifier).confirmOnce(
|
||||
userId: _userId,
|
||||
message: outInbox,
|
||||
draft: outBundle.draft,
|
||||
accountId: _accA,
|
||||
);
|
||||
await _waitFor(repo, out.id,
|
||||
(m) => m.status == RawMessageStatus.applied && m.transactionId != null);
|
||||
|
||||
await settings.setTransferPairingEnabled(true);
|
||||
final inn = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: _bankB,
|
||||
body: _inBody,
|
||||
receivedAt: t0.add(const Duration(minutes: 2)),
|
||||
);
|
||||
|
||||
final merged = await _waitFor(
|
||||
repo,
|
||||
inn.id,
|
||||
(m) => m.status == RawMessageStatus.applied && m.transactionId != null,
|
||||
);
|
||||
final txRepo = container.read(transactionRepositoryProvider);
|
||||
final tx = (await txRepo.findById(merged.transactionId!))!;
|
||||
expect(tx.type, TransactionType.transfer);
|
||||
expect(tx.accountId, _accA);
|
||||
expect(tx.transferToAccountId, _accB);
|
||||
expect(tx.categoryId, isNull);
|
||||
|
||||
final innBundle = decodeDraftBundle(merged.draftJson)!;
|
||||
expect(innBundle.mergeUndo, isNotNull);
|
||||
expect(innBundle.mergeUndo!['prevType'], 'expense');
|
||||
expect(merged.pairedWithId, out.id);
|
||||
|
||||
// Откат доклейки из журнала.
|
||||
await container.read(inboxControllerProvider.notifier).unmergeApplied(
|
||||
userId: _userId,
|
||||
message: merged,
|
||||
);
|
||||
final restoredTx = (await txRepo.findById(tx.id))!;
|
||||
expect(restoredTx.type, TransactionType.expense);
|
||||
expect(restoredTx.transferToAccountId, isNull);
|
||||
|
||||
final innAfter = (await repo.findById(inn.id))!;
|
||||
expect(innAfter.status, RawMessageStatus.inbox);
|
||||
expect(innAfter.transactionId, isNull);
|
||||
expect(innAfter.pairedWithId, isNull);
|
||||
expect(decodeDraftBundle(innAfter.draftJson)!.mergeUndo, isNull);
|
||||
|
||||
final blocked = await container
|
||||
.read(transferPairingBlocklistRepositoryProvider)
|
||||
.contains(_userId, pairSignature(out.id, inn.id));
|
||||
expect(blocked, isTrue);
|
||||
});
|
||||
|
||||
test('расклейка из Inbox: preMerge восстановлен, повторный матч заблокирован',
|
||||
() async {
|
||||
await bootstrap();
|
||||
_activateWorker(container);
|
||||
|
||||
final (out, inn) = await mergePairFlow();
|
||||
|
||||
await container.read(inboxControllerProvider.notifier).unpair(
|
||||
userId: _userId,
|
||||
message: out,
|
||||
);
|
||||
|
||||
final outAfter = (await repo.findById(out.id))!;
|
||||
expect(outAfter.status, RawMessageStatus.inbox);
|
||||
expect(outAfter.pairedWithId, isNull);
|
||||
final outBundle = decodeDraftBundle(outAfter.draftJson)!;
|
||||
expect(outBundle.pairedRawMessageId, isNull);
|
||||
expect(outBundle.draft.type, TransactionType.expense,
|
||||
reason: 'тип восстановлен из preMerge');
|
||||
expect(outBundle.draft.transferToAccountId, isNull);
|
||||
|
||||
final innAfter = (await repo.findById(inn.id))!;
|
||||
expect(innAfter.status, RawMessageStatus.inbox);
|
||||
expect(innAfter.pairedWithId, isNull);
|
||||
expect(decodeDraftBundle(innAfter.draftJson)!.draft.kind,
|
||||
TxKind.transferIn);
|
||||
|
||||
final blocked = await container
|
||||
.read(transferPairingBlocklistRepositoryProvider)
|
||||
.contains(_userId, pairSignature(out.id, inn.id));
|
||||
expect(blocked, isTrue);
|
||||
|
||||
// Повторный матч заблокирован: возвращаем secondary в ожидание пары —
|
||||
// sweep не должен склеить её с primary, стоящим в Inbox.
|
||||
await repo.holdForPairing(
|
||||
id: inn.id,
|
||||
draftJson: innAfter.draftJson!,
|
||||
deadline: DateTime.now().add(const Duration(minutes: 5)),
|
||||
);
|
||||
await container.read(parsingPipelineProvider).sweepPairs(_userId);
|
||||
final stillWaiting = (await repo.findById(inn.id))!;
|
||||
expect(stillWaiting.status, RawMessageStatus.waitingPair,
|
||||
reason: 'blocklist не даёт склеить ту же пару снова');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/app_database.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart';
|
||||
|
||||
/// `watchTopCategoryIds` — данные для чипов «частых категорий» на карточке
|
||||
/// Inbox: категории подтверждённых транзакций из уведомлений одного
|
||||
/// приложения, по числу транзакций (при равенстве — по свежести), максимум 4.
|
||||
|
||||
const _userId = 'u1';
|
||||
const _accountId = 'acc1';
|
||||
const _ozon = 'ru.ozon.app.android';
|
||||
const _bank = 'ru.sberbankmobile';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late RawMessagesRepositoryImpl repo;
|
||||
var seq = 0;
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
repo = RawMessagesRepositoryImpl(RawMessagesDao(db));
|
||||
seq = 0;
|
||||
// FK-цепочка: user → account → categories.
|
||||
await db.usersDao.insertUser(
|
||||
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||||
);
|
||||
await db.accountsDao.insertAccount(
|
||||
AccountsTableCompanion.insert(id: _accountId, userId: _userId, name: 'Main'),
|
||||
);
|
||||
for (final id in ['cat-a', 'cat-b', 'cat-c', 'cat-d', 'cat-e']) {
|
||||
await db.categoriesDao.insertCategory(
|
||||
CategoriesTableCompanion.insert(id: id, userId: _userId, name: id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
/// Транзакция, «подтверждённая» из уведомления [packageName]
|
||||
/// (rawMessageId != null); при packageName == null — ручная, без сообщения.
|
||||
Future<void> confirmTx({
|
||||
String? packageName = _ozon,
|
||||
String? categoryId,
|
||||
required DateTime date,
|
||||
TransactionType type = TransactionType.expense,
|
||||
}) async {
|
||||
final n = seq++;
|
||||
String? msgId;
|
||||
if (packageName != null) {
|
||||
final msg = await repo.insertIncoming(
|
||||
userId: _userId,
|
||||
packageName: packageName,
|
||||
body: 'Покупка №$n',
|
||||
receivedAt: date,
|
||||
);
|
||||
msgId = msg.id;
|
||||
}
|
||||
await db.into(db.transactionsTable).insert(
|
||||
TransactionsTableCompanion.insert(
|
||||
id: 'tx-$n',
|
||||
userId: _userId,
|
||||
accountId: _accountId,
|
||||
categoryId: Value(categoryId),
|
||||
type: Value(type),
|
||||
amount: 100,
|
||||
date: date,
|
||||
rawMessageId: Value(msgId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test('порядок: по частоте, при равенстве — по свежести; лимит 4', () async {
|
||||
DateTime day(int d) => DateTime(2026, 6, d);
|
||||
|
||||
// cat-a ×3, cat-b ×2; cat-c/d/e ×1 — свежесть e > c > d.
|
||||
for (final d in [1, 2, 3]) {
|
||||
await confirmTx(categoryId: 'cat-a', date: day(d));
|
||||
}
|
||||
for (final d in [4, 5]) {
|
||||
await confirmTx(categoryId: 'cat-b', date: day(d));
|
||||
}
|
||||
await confirmTx(categoryId: 'cat-d', date: day(6));
|
||||
await confirmTx(categoryId: 'cat-c', date: day(7));
|
||||
await confirmTx(categoryId: 'cat-e', date: day(8));
|
||||
|
||||
final top = await repo
|
||||
.watchTopCategoryIds(_userId, _ozon, TransactionType.expense)
|
||||
.first;
|
||||
|
||||
// cat-d (5-я по рангу) не влезла в лимит 4.
|
||||
expect(top, ['cat-a', 'cat-b', 'cat-e', 'cat-c']);
|
||||
});
|
||||
|
||||
test('фильтры: тип, пакет, ручные транзакции и null-категории не считаются',
|
||||
() async {
|
||||
final date = DateTime(2026, 6, 10);
|
||||
await confirmTx(categoryId: 'cat-a', date: date);
|
||||
// Не должны попасть в выборку:
|
||||
await confirmTx(categoryId: 'cat-b', date: date, type: TransactionType.income);
|
||||
await confirmTx(categoryId: 'cat-c', date: date, packageName: _bank);
|
||||
await confirmTx(categoryId: 'cat-d', date: date, packageName: null);
|
||||
await confirmTx(categoryId: null, date: date);
|
||||
|
||||
final top = await repo
|
||||
.watchTopCategoryIds(_userId, _ozon, TransactionType.expense)
|
||||
.first;
|
||||
expect(top, ['cat-a']);
|
||||
|
||||
// Income-выборка того же пакета видит только income-транзакцию.
|
||||
final topIncome = await repo
|
||||
.watchTopCategoryIds(_userId, _ozon, TransactionType.income)
|
||||
.first;
|
||||
expect(topIncome, ['cat-b']);
|
||||
});
|
||||
}
|
||||
@@ -103,12 +103,9 @@ Future<void> _seed(AppDatabase db, {List<String> categories = const []}) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// Активирует воркер: помимо чтения самого провайдера держим прямого слушателя
|
||||
/// на его входном потоке [pendingMessagesProvider]. Без внешнего слушателя
|
||||
/// autoDispose-стрим не подписывается на Drift и `ref.listen` внутри воркера не
|
||||
/// получает событий (в приложении эту роль играет `HomeScreen`).
|
||||
/// Активирует воркер: его входы — прямые подписки на Drift-стримы репозитория,
|
||||
/// поэтому одного чтения провайдера достаточно.
|
||||
void _activateWorker(ProviderContainer c) {
|
||||
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
|
||||
c.read(parsingWorkerProvider(_userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -63,5 +63,56 @@ void main() {
|
||||
expect(decodeDraftBundle(null), isNull);
|
||||
expect(decodeDraftBundle(''), isNull);
|
||||
});
|
||||
|
||||
test('поля пейринга (pairedRawMessageId/preMerge/mergeUndo) round-trip',
|
||||
() {
|
||||
final draft = ParseDraft(
|
||||
rawMessageId: 'm1',
|
||||
type: TransactionType.transfer,
|
||||
amount: 500000,
|
||||
kind: TxKind.transferOut,
|
||||
accountId: 'a1',
|
||||
transferToAccountId: 'a2',
|
||||
source: ParseSource.ai,
|
||||
);
|
||||
final decoded = decodeDraftBundle(encodeDraftBundle(
|
||||
draft,
|
||||
null,
|
||||
accountTrusted: true,
|
||||
pairedRawMessageId: 'm2',
|
||||
preMerge: {'type': 'expense', 'categoryId': 'c1'},
|
||||
mergeUndo: {
|
||||
'txId': 't1',
|
||||
'prevType': 'income',
|
||||
'prevAccountId': 'a2',
|
||||
'prevCategoryId': null,
|
||||
'prevTransferToAccountId': null,
|
||||
},
|
||||
));
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.accountTrusted, isTrue);
|
||||
expect(decoded.pairedRawMessageId, 'm2');
|
||||
expect(decoded.preMerge, {'type': 'expense', 'categoryId': 'c1'});
|
||||
expect(decoded.mergeUndo!['txId'], 't1');
|
||||
expect(decoded.mergeUndo!['prevType'], 'income');
|
||||
expect(decoded.mergeUndo!['prevAccountId'], 'a2');
|
||||
expect(decoded.draft.transferToAccountId, 'a2');
|
||||
});
|
||||
|
||||
test('старый JSON без полей пейринга декодится с дефолтами', () {
|
||||
final draft = ParseDraft(
|
||||
rawMessageId: 'm1',
|
||||
type: TransactionType.expense,
|
||||
amount: 100,
|
||||
source: ParseSource.ai,
|
||||
);
|
||||
// encode без новых аргументов = формат до transfer pairing.
|
||||
final decoded = decodeDraftBundle(encodeDraftBundle(draft, null));
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.accountTrusted, isFalse);
|
||||
expect(decoded.pairedRawMessageId, isNull);
|
||||
expect(decoded.preMerge, isNull);
|
||||
expect(decoded.mergeUndo, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,40 +154,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('findMixedMerchantRule', () {
|
||||
const body = 'Покупка OZON 1240';
|
||||
|
||||
test('finds enabled mixedMerchant rule matching the merchant', () {
|
||||
final r = findMixedMerchantRule(
|
||||
[
|
||||
_rule(
|
||||
id: 'm',
|
||||
pattern: 'OZON',
|
||||
kind: ParseRuleKind.mixedMerchant,
|
||||
categoryId: null),
|
||||
],
|
||||
body: body,
|
||||
);
|
||||
expect(r?.id, 'm');
|
||||
});
|
||||
|
||||
test('ignores merchantToCategory and disabled rules', () {
|
||||
final r = findMixedMerchantRule(
|
||||
[
|
||||
_rule(id: 'a', pattern: 'OZON'),
|
||||
_rule(
|
||||
id: 'b',
|
||||
pattern: 'OZON',
|
||||
kind: ParseRuleKind.mixedMerchant,
|
||||
enabled: false,
|
||||
categoryId: null),
|
||||
],
|
||||
body: body,
|
||||
);
|
||||
expect(r, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findIgnoreRule', () {
|
||||
test('finds enabled ignore rule', () {
|
||||
final r = findIgnoreRule(
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/transfer_pair_matcher.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
|
||||
ParseDraft _draft({
|
||||
String id = 'm',
|
||||
TxKind? kind,
|
||||
int amount = 500000,
|
||||
String currency = 'RUB',
|
||||
String? accountId,
|
||||
}) =>
|
||||
ParseDraft(
|
||||
rawMessageId: id,
|
||||
type: kind == TxKind.transferOut
|
||||
? TransactionType.expense
|
||||
: TransactionType.income,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
kind: kind,
|
||||
accountId: accountId,
|
||||
source: ParseSource.ai,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final t0 = DateTime(2026, 7, 1, 12);
|
||||
|
||||
group('isTransferHalf', () {
|
||||
test('transferOut/transferIn — да, остальное — нет', () {
|
||||
expect(isTransferHalf(_draft(kind: TxKind.transferOut)), isTrue);
|
||||
expect(isTransferHalf(_draft(kind: TxKind.transferIn)), isTrue);
|
||||
expect(isTransferHalf(_draft(kind: TxKind.purchase)), isFalse);
|
||||
expect(isTransferHalf(_draft(kind: TxKind.fee)), isFalse);
|
||||
expect(isTransferHalf(_draft(kind: null)), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('isPair', () {
|
||||
test('противоположные kind, равные суммы, окно 5 мин → пара', () {
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: TxKind.transferOut, accountId: 'a1'),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferIn, accountId: 'a2'),
|
||||
bReceivedAt: t0.add(const Duration(minutes: 3)),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('одинаковые kind → не пара', () {
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: TxKind.transferOut),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferOut),
|
||||
bReceivedAt: t0,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('kind=null не участвует', () {
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: null),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferIn),
|
||||
bReceivedAt: t0,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('разные суммы или валюты → не пара', () {
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: TxKind.transferOut, amount: 500000),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferIn, amount: 500001),
|
||||
bReceivedAt: t0,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: TxKind.transferOut, currency: 'RUB'),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferIn, currency: 'USD'),
|
||||
bReceivedAt: t0,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('вне окна 5 минут → не пара', () {
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: TxKind.transferOut),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferIn),
|
||||
bReceivedAt: t0.add(const Duration(minutes: 5, seconds: 1)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('оба счёта разрешены и совпали → не пара; один null → пара', () {
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: TxKind.transferOut, accountId: 'a1'),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferIn, accountId: 'a1'),
|
||||
bReceivedAt: t0,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
isPair(
|
||||
a: _draft(kind: TxKind.transferOut, accountId: 'a1'),
|
||||
aReceivedAt: t0,
|
||||
b: _draft(kind: TxKind.transferIn, accountId: null),
|
||||
bReceivedAt: t0,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('pickNearest', () {
|
||||
test('выбирает ближайшего по receivedAt', () {
|
||||
final near = t0.add(const Duration(minutes: 1));
|
||||
final far = t0.add(const Duration(minutes: 4));
|
||||
expect(pickNearest([('far', far), ('near', near)], t0), 'near');
|
||||
});
|
||||
});
|
||||
|
||||
group('pairSignature', () {
|
||||
test('порядконезависима', () {
|
||||
expect(pairSignature('b', 'a'), pairSignature('a', 'b'));
|
||||
expect(pairSignature('a', 'b'), 'a|b');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.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/domain/entities/category.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
||||
@@ -13,6 +17,7 @@ import 'package:new_budget/src/features/notification_parsing/domain/entities/raw
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/entities/rule_suggestion.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
||||
import 'package:new_budget/src/features/notification_parsing/presentation/widgets/inbox_card.dart';
|
||||
import 'package:new_budget/src/features/transactions/presentation/screens/transaction_form_screen.dart';
|
||||
|
||||
final _now = DateTime(2026, 5, 29, 14, 5);
|
||||
|
||||
@@ -20,10 +25,11 @@ class FakeInboxController extends InboxController {
|
||||
int createRuleCalls = 0;
|
||||
int confirmOnceCalls = 0;
|
||||
int ignoreCalls = 0;
|
||||
int markMixedCalls = 0;
|
||||
String? lastCreateRuleCategory;
|
||||
String? lastCreateRuleAccount;
|
||||
String? lastConfirmCategory;
|
||||
String? lastMarkMixedMerchant;
|
||||
String? lastConfirmAccount;
|
||||
final List<(String, String)> appliedCalls = [];
|
||||
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
@@ -42,6 +48,7 @@ class FakeInboxController extends InboxController {
|
||||
}) async {
|
||||
createRuleCalls++;
|
||||
lastCreateRuleCategory = categoryId;
|
||||
lastCreateRuleAccount = accountId;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -55,17 +62,12 @@ class FakeInboxController extends InboxController {
|
||||
}) async {
|
||||
confirmOnceCalls++;
|
||||
lastConfirmCategory = categoryId;
|
||||
lastConfirmAccount = accountId;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markMerchantMixed({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required DraftBundle bundle,
|
||||
required String merchantCanonical,
|
||||
}) async {
|
||||
markMixedCalls++;
|
||||
lastMarkMixedMerchant = merchantCanonical;
|
||||
Future<void> markApplied(RawMessage message, String transactionId) async {
|
||||
appliedCalls.add((message.id, transactionId));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -107,7 +109,7 @@ RawMessage _recognizedMessage() {
|
||||
}
|
||||
|
||||
/// Уведомление без мерчанта (только сумма) → нет suggestion → карточка
|
||||
/// показывает «подтвердить разово» без кнопки «Создать правило».
|
||||
/// показывает «Подтвердить» без кнопки «Создать правило».
|
||||
/// [categorySuggestion] — AI-подсказка категории (по имени), которую карточка
|
||||
/// должна предзаполнить в пикере.
|
||||
RawMessage _amountOnlyMessage({String? categorySuggestion}) {
|
||||
@@ -132,14 +134,44 @@ RawMessage _amountOnlyMessage({String? categorySuggestion}) {
|
||||
);
|
||||
}
|
||||
|
||||
const _categoryById = {
|
||||
'cat1': Category(
|
||||
id: 'cat1',
|
||||
userId: 'u1',
|
||||
name: 'Продукты',
|
||||
type: CategoryType.expense,
|
||||
archived: false,
|
||||
),
|
||||
};
|
||||
|
||||
/// Герметичные overrides по умолчанию: чипы «частых категорий» — пустой стрим
|
||||
/// (иначе провайдер полез бы в реальную БД).
|
||||
List<Override> _baseOverrides(
|
||||
FakeInboxController fake, {
|
||||
List<String> topCategories = const [],
|
||||
}) {
|
||||
return [
|
||||
inboxControllerProvider.overrideWith(() => fake),
|
||||
topConfirmCategoriesProvider(
|
||||
'u1', 'com.example.wallet', TransactionType.expense)
|
||||
.overrideWith((ref) => Stream.value(topCategories)),
|
||||
topConfirmCategoriesProvider(
|
||||
'u1', 'ru.sberbankmobile', TransactionType.expense)
|
||||
.overrideWith((ref) => Stream.value(topCategories)),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _host(
|
||||
FakeInboxController fake,
|
||||
RawMessage message, {
|
||||
String? defaultAccountId = 'acc1',
|
||||
List<String> topCategories = const [],
|
||||
List<Override> extraOverrides = const [],
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
inboxControllerProvider.overrideWith(() => fake),
|
||||
..._baseOverrides(fake, topCategories: topCategories),
|
||||
...extraOverrides,
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
@@ -151,15 +183,7 @@ Widget _host(
|
||||
message: message,
|
||||
userId: 'u1',
|
||||
defaultAccountId: defaultAccountId,
|
||||
categoryById: const {
|
||||
'cat1': Category(
|
||||
id: 'cat1',
|
||||
userId: 'u1',
|
||||
name: 'Продукты',
|
||||
type: CategoryType.expense,
|
||||
archived: false,
|
||||
),
|
||||
},
|
||||
categoryById: _categoryById,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -197,7 +221,7 @@ void main() {
|
||||
expect(fake.lastCreateRuleCategory, 'cat1');
|
||||
});
|
||||
|
||||
testWidgets('tap confirm-once calls controller', (tester) async {
|
||||
testWidgets('tap confirm calls controller', (tester) async {
|
||||
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
||||
await tester.pump();
|
||||
|
||||
@@ -223,18 +247,23 @@ void main() {
|
||||
await tester.pumpWidget(_host(fake, _amountOnlyMessage()));
|
||||
await tester.pump();
|
||||
|
||||
// Нет мерчанта → кнопки «Создать правило» и «Категории различаются» нет.
|
||||
// Нет мерчанта → кнопки «Создать правило» нет, есть «Подтвердить».
|
||||
expect(find.textContaining('Создать правило'), findsNothing);
|
||||
expect(find.text(l10n.inboxMarkMixed), findsNothing);
|
||||
|
||||
// Есть основная кнопка «Подтвердить».
|
||||
expect(find.text(l10n.inboxConfirm), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('confirm inactive without category, hint is shown',
|
||||
(tester) async {
|
||||
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||
await tester.pumpWidget(_host(fake, _amountOnlyMessage()));
|
||||
await tester.pump();
|
||||
|
||||
// Категория не выбрана → подсказка видна, тап по «Подтвердить» — ничего.
|
||||
expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.inboxConfirm));
|
||||
await tester.pump();
|
||||
|
||||
expect(fake.confirmOnceCalls, 1);
|
||||
expect(fake.createRuleCalls, 0);
|
||||
expect(fake.confirmOnceCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('confirm-once pre-fills category from AI hint and passes it',
|
||||
@@ -246,6 +275,8 @@ void main() {
|
||||
|
||||
// AI-подсказка «Продукты» сматчена на cat1 и показана в пикере.
|
||||
expect(find.text('Продукты'), findsOneWidget);
|
||||
// Категория есть → подсказки «выберите категорию» нет.
|
||||
expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing);
|
||||
|
||||
await tester.tap(find.text(l10n.inboxConfirm));
|
||||
await tester.pump();
|
||||
@@ -254,38 +285,204 @@ void main() {
|
||||
expect(fake.lastConfirmCategory, 'cat1');
|
||||
});
|
||||
|
||||
testWidgets('confirm disabled when no account resolves', (tester) async {
|
||||
testWidgets('category chip selects category and is passed to confirmOnce',
|
||||
(tester) async {
|
||||
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||
// Нет дефолтного счёта и draft.accountId == null → подтвердить нельзя.
|
||||
await tester.pumpWidget(
|
||||
_host(fake, _amountOnlyMessage(), defaultAccountId: null));
|
||||
await tester.pumpWidget(_host(
|
||||
fake,
|
||||
_amountOnlyMessage(),
|
||||
topCategories: const ['cat1'],
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.ancestor(
|
||||
of: find.text(l10n.inboxConfirm),
|
||||
matching: find.byType(FilledButton),
|
||||
),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
// Чип «Продукты» виден; категория ещё не выбрана.
|
||||
expect(find.text('Продукты'), findsOneWidget);
|
||||
expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Продукты').first);
|
||||
await tester.pump();
|
||||
|
||||
// Выбор чипа активирует «Подтвердить».
|
||||
expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing);
|
||||
|
||||
await tester.tap(find.text(l10n.inboxConfirm));
|
||||
await tester.pump();
|
||||
expect(fake.confirmOnceCalls, 0);
|
||||
|
||||
expect(fake.confirmOnceCalls, 1);
|
||||
expect(fake.lastConfirmCategory, 'cat1');
|
||||
});
|
||||
|
||||
testWidgets('"categories vary" marks merchant mixed', (tester) async {
|
||||
testWidgets('create rule without account opens the account picker',
|
||||
(tester) async {
|
||||
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
||||
final account = Account(
|
||||
id: 'acc-x',
|
||||
userId: 'u1',
|
||||
name: 'Основной',
|
||||
type: AccountType.card,
|
||||
currency: 'RUB',
|
||||
initialBalance: 0,
|
||||
archived: false,
|
||||
createdAt: _now,
|
||||
);
|
||||
// Нет дефолтного счёта и draft.accountId == null → «Создать правило»
|
||||
// открывает пикер счёта вместо молчаливого no-op.
|
||||
await tester.pumpWidget(_host(
|
||||
fake,
|
||||
_recognizedMessage(),
|
||||
defaultAccountId: null,
|
||||
extraOverrides: [
|
||||
accountsStreamProvider('u1')
|
||||
.overrideWith((ref) => Stream.value([account])),
|
||||
accountBalanceProvider('acc-x')
|
||||
.overrideWith((ref) => Stream.value(0)),
|
||||
],
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(l10n.inboxMarkMixed), findsOneWidget);
|
||||
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text(l10n.inboxMarkMixed));
|
||||
// Открылся пикер счёта, правило ещё не создано.
|
||||
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
|
||||
expect(fake.createRuleCalls, 0);
|
||||
|
||||
// Имя счёта в тайле пикера встречается дважды (название + подпись).
|
||||
await tester.tap(find.text('Основной').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fake.createRuleCalls, 1);
|
||||
expect(fake.lastCreateRuleAccount, 'acc-x');
|
||||
expect(fake.lastCreateRuleCategory, 'cat1');
|
||||
});
|
||||
|
||||
testWidgets('create rule: cancelling the account picker does nothing',
|
||||
(tester) async {
|
||||
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||
await tester.pumpWidget(_host(
|
||||
fake,
|
||||
_recognizedMessage(),
|
||||
defaultAccountId: null,
|
||||
extraOverrides: [
|
||||
accountsStreamProvider('u1')
|
||||
.overrideWith((ref) => Stream.value(const [])),
|
||||
],
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(fake.markMixedCalls, 1);
|
||||
expect(fake.lastMarkMixedMerchant, 'PYATEROCHKA');
|
||||
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
|
||||
|
||||
// Тап по барьеру закрывает пикер без выбора.
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fake.createRuleCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('confirm without account opens the account picker',
|
||||
(tester) async {
|
||||
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
||||
final account = Account(
|
||||
id: 'acc-x',
|
||||
userId: 'u1',
|
||||
name: 'Основной',
|
||||
type: AccountType.card,
|
||||
currency: 'RUB',
|
||||
initialBalance: 0,
|
||||
archived: false,
|
||||
createdAt: _now,
|
||||
);
|
||||
// Нет дефолтного счёта и draft.accountId == null → «Подтвердить» открывает
|
||||
// пикер счёта; выбор в нём завершает подтверждение.
|
||||
await tester.pumpWidget(_host(
|
||||
fake,
|
||||
_amountOnlyMessage(categorySuggestion: 'Продукты'),
|
||||
defaultAccountId: null,
|
||||
extraOverrides: [
|
||||
accountsStreamProvider('u1')
|
||||
.overrideWith((ref) => Stream.value([account])),
|
||||
accountBalanceProvider('acc-x')
|
||||
.overrideWith((ref) => Stream.value(0)),
|
||||
],
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text(l10n.inboxConfirm));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Открылся пикер счёта.
|
||||
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
|
||||
expect(fake.confirmOnceCalls, 0);
|
||||
|
||||
// Имя счёта в тайле пикера встречается дважды (название + подпись).
|
||||
await tester.tap(find.text('Основной').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fake.confirmOnceCalls, 1);
|
||||
expect(fake.lastConfirmAccount, 'acc-x');
|
||||
});
|
||||
|
||||
testWidgets('pencil opens full form with prefill and links the saved tx',
|
||||
(tester) async {
|
||||
TransactionFormPrefill? capturedPrefill;
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => Scaffold(
|
||||
body: InboxCard(
|
||||
message: _amountOnlyMessage(),
|
||||
userId: 'u1',
|
||||
defaultAccountId: 'acc1',
|
||||
categoryById: _categoryById,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/transactions/new',
|
||||
builder: (context, state) {
|
||||
capturedPrefill = state.extra as TransactionFormPrefill?;
|
||||
return Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => context.pop('tx-9'),
|
||||
child: const Text('SAVE-STUB'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: _baseOverrides(fake),
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
theme: AppTheme.light(),
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
// Карандаш активен даже без категории.
|
||||
await tester.tap(find.byIcon(Icons.edit_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(capturedPrefill, isNotNull);
|
||||
expect(capturedPrefill!.amountMinor, 50000);
|
||||
expect(capturedPrefill!.type, TransactionType.expense);
|
||||
expect(capturedPrefill!.accountId, 'acc1');
|
||||
expect(capturedPrefill!.rawMessageId, 'msg2');
|
||||
|
||||
// «Сохранение» в форме возвращает id → сообщение линкуется.
|
||||
await tester.tap(find.text('SAVE-STUB'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fake.appliedCalls, contains(('msg2', 'tx-9')));
|
||||
expect(fake.confirmOnceCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('unrecognized message shows manual-add fallback', (tester) async {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:new_budget/l10n/app_localizations.dart';
|
||||
@@ -50,6 +51,7 @@ class TxCall {
|
||||
required this.date,
|
||||
this.merchant,
|
||||
this.transferToAccountId,
|
||||
this.rawMessageId,
|
||||
});
|
||||
final String userId;
|
||||
final String accountId;
|
||||
@@ -59,6 +61,7 @@ class TxCall {
|
||||
final DateTime date;
|
||||
final String? merchant;
|
||||
final String? transferToAccountId;
|
||||
final String? rawMessageId;
|
||||
}
|
||||
|
||||
class FakeTransactionsController extends TransactionsController {
|
||||
@@ -93,6 +96,7 @@ class FakeTransactionsController extends TransactionsController {
|
||||
date: date,
|
||||
merchant: merchant,
|
||||
transferToAccountId: transferToAccountId,
|
||||
rawMessageId: rawMessageId,
|
||||
));
|
||||
return Transaction(
|
||||
id: 'tx-1',
|
||||
@@ -118,9 +122,7 @@ class _UnusedTransactionRepo implements TransactionRepository {
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildForm(FakeTransactionsController fakeCtrl) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
List<Override> _formOverrides(FakeTransactionsController fakeCtrl) => [
|
||||
activeUserControllerProvider.overrideWith(() => FakeActiveUserController()),
|
||||
settingsControllerProvider('test-uid')
|
||||
.overrideWith(() => FakeSettingsController()),
|
||||
@@ -139,12 +141,19 @@ Widget _buildForm(FakeTransactionsController fakeCtrl) {
|
||||
categoriesByTypeStreamProvider('test-uid', CategoryType.income).overrideWith(
|
||||
(ref) => Stream<List<Category>>.value(const []),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
Widget _buildForm(
|
||||
FakeTransactionsController fakeCtrl, {
|
||||
TransactionFormPrefill? prefill,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: _formOverrides(fakeCtrl),
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const TransactionFormScreen(),
|
||||
home: TransactionFormScreen(prefill: prefill),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -346,4 +355,86 @@ void main() {
|
||||
expect(fakeCtrl.calls, hasLength(1));
|
||||
expect(fakeCtrl.calls.first.merchant, isNull);
|
||||
});
|
||||
|
||||
// ── Префилл из Inbox ──────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('Префилл гидратирует поля; save передаёт rawMessageId',
|
||||
(tester) async {
|
||||
final testDate = DateTime(2026, 5, 29, 14, 5);
|
||||
await tester.pumpWidget(_buildForm(
|
||||
fakeCtrl,
|
||||
prefill: TransactionFormPrefill(
|
||||
type: TransactionType.expense,
|
||||
amountMinor: 50000,
|
||||
date: testDate,
|
||||
accountId: 'a-1',
|
||||
categoryId: 'cat-1',
|
||||
merchant: 'Ozon',
|
||||
rawMessageId: 'msg-2',
|
||||
),
|
||||
));
|
||||
await tester.pump(); // activeUser разрешается
|
||||
await tester.pump(); // post-frame гидрация черновика
|
||||
|
||||
// Сумма из префилла видна в поле ввода.
|
||||
expect(find.text('500'), findsOneWidget);
|
||||
expect(find.text('Ozon'), findsOneWidget);
|
||||
|
||||
await _tapSave(tester);
|
||||
|
||||
expect(fakeCtrl.calls, hasLength(1));
|
||||
final call = fakeCtrl.calls.first;
|
||||
expect(call.amount, 50000);
|
||||
expect(call.accountId, 'a-1');
|
||||
expect(call.categoryId, 'cat-1');
|
||||
expect(call.date, testDate);
|
||||
expect(call.merchant, 'Ozon');
|
||||
expect(call.rawMessageId, 'msg-2');
|
||||
});
|
||||
|
||||
testWidgets('Создание возвращает id транзакции через Navigator.pop',
|
||||
(tester) async {
|
||||
String? poppedId;
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: _formOverrides(fakeCtrl),
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () async {
|
||||
poppedId = await Navigator.of(context).push<String?>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TransactionFormScreen(
|
||||
prefill: TransactionFormPrefill(
|
||||
type: TransactionType.expense,
|
||||
amountMinor: 50000,
|
||||
date: DateTime(2026, 5, 29),
|
||||
accountId: 'a-1',
|
||||
categoryId: 'cat-1',
|
||||
rawMessageId: 'msg-2',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('OPEN'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.text('OPEN'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _tapSave(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Форма закрылась и вернула id созданной транзакции (fake отдаёт 'tx-1').
|
||||
expect(poppedId, 'tx-1');
|
||||
expect(find.text('OPEN'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ 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/user/application/active_user_controller.dart';
|
||||
import 'package:new_budget/src/features/user/application/users_controller.dart';
|
||||
import 'package:new_budget/src/features/user/domain/entities/user.dart';
|
||||
@@ -13,10 +16,9 @@ import 'package:new_budget/src/features/user/presentation/screens/onboarding_scr
|
||||
|
||||
// ─── Fakes ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// FakeUsersController и FakeActiveUserController расширяют реальные контроллеры
|
||||
// и переопределяют только те методы, которые касаются БД. Riverpod создаёт их
|
||||
// через фабрику в overrideWith, поэтому ref.watch/ref.read внутри build()
|
||||
// никогда не вызываются.
|
||||
// Fake-контроллеры расширяют реальные контроллеры и переопределяют только те
|
||||
// методы, которые касаются БД. Riverpod создаёт их через фабрику в overrideWith,
|
||||
// поэтому ref.watch/ref.read внутри build() никогда не вызываются.
|
||||
|
||||
class FakeUsersController extends UsersController {
|
||||
/// Имена, с которыми вызывался createUser.
|
||||
@@ -40,6 +42,44 @@ class FakeUsersController extends UsersController {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAccountsController extends AccountsController {
|
||||
final List<({String userId, String name, AccountType type})> created = [];
|
||||
final List<(String? id, String userId)> setDefaultCalls = [];
|
||||
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
|
||||
@override
|
||||
Future<Account> createAccount({
|
||||
required String userId,
|
||||
required String name,
|
||||
required AccountType type,
|
||||
required String currency,
|
||||
int initialBalance = 0,
|
||||
int? iconCode,
|
||||
int? colorValue,
|
||||
}) async {
|
||||
created.add((userId: userId, name: name, type: type));
|
||||
return Account(
|
||||
id: 'acc-${created.length}',
|
||||
userId: userId,
|
||||
name: name,
|
||||
type: type,
|
||||
currency: currency,
|
||||
initialBalance: initialBalance,
|
||||
iconCode: iconCode,
|
||||
colorValue: colorValue,
|
||||
archived: false,
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDefaultAccount(String? id, String userId) async {
|
||||
setDefaultCalls.add((id, userId));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeActiveUserController extends ActiveUserController {
|
||||
/// Пользователи, переданные в setActiveUser.
|
||||
final List<User> activatedUsers = [];
|
||||
@@ -59,11 +99,13 @@ class FakeActiveUserController extends ActiveUserController {
|
||||
|
||||
Widget _buildOnboarding({
|
||||
required FakeUsersController fakeUsers,
|
||||
required FakeAccountsController fakeAccounts,
|
||||
required FakeActiveUserController fakeActive,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
usersControllerProvider.overrideWith(() => fakeUsers),
|
||||
accountsControllerProvider.overrideWith(() => fakeAccounts),
|
||||
activeUserControllerProvider.overrideWith(() => fakeActive),
|
||||
],
|
||||
child: MaterialApp(
|
||||
@@ -75,6 +117,14 @@ Widget _buildOnboarding({
|
||||
);
|
||||
}
|
||||
|
||||
/// Заполняет имя на шаге 1 и переходит на шаг создания счёта.
|
||||
Future<void> _advanceToAccountStep(WidgetTester tester, String name) async {
|
||||
await tester.enterText(find.byType(TextField), name);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
@@ -85,202 +135,145 @@ void main() {
|
||||
|
||||
group('OnboardingScreen', () {
|
||||
late FakeUsersController fakeUsers;
|
||||
late FakeAccountsController fakeAccounts;
|
||||
late FakeActiveUserController fakeActive;
|
||||
|
||||
setUp(() {
|
||||
fakeUsers = FakeUsersController();
|
||||
fakeAccounts = FakeAccountsController();
|
||||
fakeActive = FakeActiveUserController();
|
||||
});
|
||||
|
||||
// ── Рендеринг ──────────────────────────────────────────────────────────
|
||||
Widget build() => _buildOnboarding(
|
||||
fakeUsers: fakeUsers,
|
||||
fakeAccounts: fakeAccounts,
|
||||
fakeActive: fakeActive,
|
||||
);
|
||||
|
||||
testWidgets('отображает заголовок, подзаголовок, поле имени и кнопку',
|
||||
// ── Шаг 1: имя ───────────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('шаг 1: отображает заголовок, поле имени и кнопку',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pumpWidget(build());
|
||||
await tester.pump();
|
||||
|
||||
// Локализация EN: "Welcome" / "Tell us your name to get started."
|
||||
expect(find.text('Welcome'), findsOneWidget);
|
||||
expect(find.text('Tell us your name to get started.'), findsOneWidget);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Your name'), findsOneWidget);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TextField имеет лейбл и хинт', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
testWidgets('шаг 1: пустое имя — кнопка Continue задизейблена',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(build());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Your name'), findsOneWidget);
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
// ── Валидация пустого имени ─────────────────────────────────────────────
|
||||
|
||||
testWidgets('нажатие Continue с пустым полем не вызывает createUser',
|
||||
testWidgets('шаг 1: Continue не создаёт пользователя — только переход',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pumpWidget(build());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pump();
|
||||
await _advanceToAccountStep(tester, 'Alice');
|
||||
|
||||
// Перешли на шаг счёта; пользователь ещё НЕ создан (записей в БД нет).
|
||||
expect(find.text('Your first account'), findsOneWidget);
|
||||
expect(fakeUsers.createdNames, isEmpty);
|
||||
expect(fakeActive.activatedUsers, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('нажатие Continue с пробелами не вызывает createUser',
|
||||
// ── Шаг 2: создание первого счёта ────────────────────────────────────────
|
||||
|
||||
testWidgets('шаг 2: назад возвращает на шаг имени', (tester) async {
|
||||
await tester.pumpWidget(build());
|
||||
await tester.pump();
|
||||
await _advanceToAccountStep(tester, 'Alice');
|
||||
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Welcome'), findsOneWidget);
|
||||
// Имя сохранилось.
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('шаг 2: пустое имя счёта — кнопка задизейблена',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.enterText(find.byType(TextField), ' ');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpWidget(build());
|
||||
await tester.pump();
|
||||
await _advanceToAccountStep(tester, 'Alice');
|
||||
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull);
|
||||
expect(fakeUsers.createdNames, isEmpty);
|
||||
});
|
||||
|
||||
// ── Happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('валидное имя: createUser вызывается с trim-значением',
|
||||
testWidgets(
|
||||
'шаг 2: сабмит создаёт пользователя, счёт (умолчательный) и активирует',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pumpWidget(build());
|
||||
await tester.pump();
|
||||
await _advanceToAccountStep(tester, ' Alice ');
|
||||
|
||||
await tester.enterText(find.byType(TextField), ' Alice ');
|
||||
await tester.enterText(find.byType(TextField).first, ' Кошелёк ');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
// Не pumpAndSettle: на успехе _submitting остаётся true (в реальном
|
||||
// приложении редирект уводит с экрана), а бесконечный спиннер не даёт
|
||||
// pumpAndSettle завершиться.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
// Пользователь создан с trim-именем.
|
||||
expect(fakeUsers.createdNames, ['Alice']);
|
||||
|
||||
// Счёт создан для этого пользователя.
|
||||
expect(fakeAccounts.created, hasLength(1));
|
||||
expect(fakeAccounts.created.single.name, 'Кошелёк');
|
||||
expect(fakeAccounts.created.single.userId, 'test-uid');
|
||||
|
||||
// Счёт помечен умолчательным.
|
||||
expect(fakeAccounts.setDefaultCalls, hasLength(1));
|
||||
expect(fakeAccounts.setDefaultCalls.single, ('acc-1', 'test-uid'));
|
||||
|
||||
// Пользователь активирован.
|
||||
expect(fakeActive.activatedUsers, hasLength(1));
|
||||
expect(fakeActive.activatedUsers.single.id, 'test-uid');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'после createUser вызывается setActiveUser с возвращённым пользователем',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pump();
|
||||
// ── Состояние загрузки на финальном сабмите ──────────────────────────────
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Bob');
|
||||
testWidgets('во время сабмита кнопка показывает спиннер и задизейблена',
|
||||
(tester) async {
|
||||
final completer = Completer<User>();
|
||||
fakeUsers.freezeNextCreate(completer);
|
||||
|
||||
await tester.pumpWidget(build());
|
||||
await tester.pump();
|
||||
await _advanceToAccountStep(tester, 'Alice');
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Кошелёк');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(); // тап + setState(_submitting = true)
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull);
|
||||
|
||||
completer.complete(
|
||||
User(id: 'test-uid', name: 'Alice', createdAt: DateTime(2024, 1, 1)),
|
||||
);
|
||||
// Спиннер остаётся (редиректа в тесте нет) — pumpAndSettle не завершится.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
expect(fakeActive.activatedUsers, hasLength(1));
|
||||
expect(fakeActive.activatedUsers.first.name, 'Bob');
|
||||
});
|
||||
|
||||
// ── Отправка через клавиатуру ──────────────────────────────────────────
|
||||
|
||||
testWidgets('TextInputAction.done также запускает submit', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Carol');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fakeUsers.createdNames, ['Carol']);
|
||||
expect(fakeActive.activatedUsers, isNotEmpty);
|
||||
});
|
||||
|
||||
// ── Состояние загрузки ─────────────────────────────────────────────────
|
||||
|
||||
testWidgets(
|
||||
'во время отправки кнопка показывает CircularProgressIndicator',
|
||||
(tester) async {
|
||||
final completer = Completer<User>();
|
||||
fakeUsers.freezeNextCreate(completer);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Dave');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pump(); // Обрабатываем тап + setState(_submitting=true)
|
||||
|
||||
// Пока awaiting: должен быть спиннер, кнопка «Continue» не видна.
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.text('Continue'), findsNothing);
|
||||
|
||||
// Разблокируем async-операцию.
|
||||
completer.complete(
|
||||
User(id: 'u-dave', name: 'Dave', createdAt: DateTime(2024, 1, 1)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// После завершения: спиннер исчез, кнопка снова видна.
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('во время отправки кнопка задизейблена (onPressed == null)',
|
||||
(tester) async {
|
||||
final completer = Completer<User>();
|
||||
fakeUsers.freezeNextCreate(completer);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Eve');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull,
|
||||
reason: 'Кнопка должна быть disabled во время отправки');
|
||||
|
||||
completer.complete(
|
||||
User(id: 'u-eve', name: 'Eve', createdAt: DateTime(2024, 1, 1)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final buttonAfter =
|
||||
tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(buttonAfter.onPressed, isNotNull,
|
||||
reason: 'После завершения кнопка снова активна');
|
||||
});
|
||||
|
||||
testWidgets('повторный тап во время отправки не создаёт второго пользователя',
|
||||
(tester) async {
|
||||
final completer = Completer<User>();
|
||||
fakeUsers.freezeNextCreate(completer);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Frank');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pump(); // Первый тап, _submitting = true
|
||||
|
||||
// Пробуем тапнуть снова (кнопка задизейблена, но try programmatic).
|
||||
await tester.tap(find.byType(FilledButton), warnIfMissed: false);
|
||||
await tester.pump();
|
||||
|
||||
// createUser должен был быть вызван только один раз.
|
||||
expect(fakeUsers.createdNames, hasLength(1));
|
||||
|
||||
completer.complete(
|
||||
User(id: 'u-frank', name: 'Frank', createdAt: DateTime(2024, 1, 1)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
||||
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
|
||||
import 'package:new_budget/src/features/accounts/domain/repositories/account_repository.dart';
|
||||
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
|
||||
import 'package:new_budget/src/features/categories/domain/repositories/category_repository.dart';
|
||||
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
|
||||
import 'package:new_budget/src/features/user/application/user_seeder.dart';
|
||||
|
||||
/// `seedForNewUser` засевает только категории. Счета больше не создаются
|
||||
/// автоматически — первый счёт пользователь создаёт сам на втором шаге
|
||||
/// онбординга (и там же помечает его умолчательным).
|
||||
|
||||
const _userId = 'u1';
|
||||
final _now = DateTime(2026, 6, 1);
|
||||
|
||||
class FakeAccountRepository implements AccountRepository {
|
||||
final List<Account> created = [];
|
||||
final List<(String? id, String userId)> setDefaultCalls = [];
|
||||
|
||||
@override
|
||||
Future<Account> create({
|
||||
required String userId,
|
||||
required String name,
|
||||
required AccountType type,
|
||||
required String currency,
|
||||
int initialBalance = 0,
|
||||
int? iconCode,
|
||||
int? colorValue,
|
||||
}) async {
|
||||
final account = Account(
|
||||
id: 'acc-${created.length}',
|
||||
userId: userId,
|
||||
name: name,
|
||||
type: type,
|
||||
currency: currency,
|
||||
initialBalance: initialBalance,
|
||||
iconCode: iconCode,
|
||||
colorValue: colorValue,
|
||||
archived: false,
|
||||
createdAt: _now,
|
||||
);
|
||||
created.add(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDefault(String? id, String userId) async {
|
||||
setDefaultCalls.add((id, userId));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<Account>> watchByUser(String userId) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<Account?> findById(String id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<Account> update(Account account) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> archive(String id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Stream<Account?> watchDefault(String userId) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Stream<int> watchBalance(String accountId) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
class FakeCategoryRepository implements CategoryRepository {
|
||||
final List<Category> created = [];
|
||||
|
||||
@override
|
||||
Future<Category> create({
|
||||
required String userId,
|
||||
required String name,
|
||||
required CategoryType type,
|
||||
int? iconCode,
|
||||
int? colorValue,
|
||||
String? parentId,
|
||||
}) async {
|
||||
final category = Category(
|
||||
id: 'cat-${created.length}',
|
||||
userId: userId,
|
||||
name: name,
|
||||
type: type,
|
||||
iconCode: iconCode,
|
||||
colorValue: colorValue,
|
||||
parentId: parentId,
|
||||
archived: false,
|
||||
);
|
||||
created.add(category);
|
||||
return category;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<Category>> watchByUser(String userId) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Stream<List<Category>> watchByType(String userId, CategoryType type) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<Category?> findById(String id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<Category> update(Category category) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> archive(String id) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Демо-транзакции при `seedForNewUser` не создаются — любой вызов = ошибка.
|
||||
class _UnusedTransactionRepository implements TransactionRepository {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw StateError('seedForNewUser не должен трогать транзакции');
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeAccountRepository accountRepo;
|
||||
late FakeCategoryRepository categoryRepo;
|
||||
late UserSeeder seeder;
|
||||
|
||||
setUp(() {
|
||||
accountRepo = FakeAccountRepository();
|
||||
categoryRepo = FakeCategoryRepository();
|
||||
seeder = UserSeeder(
|
||||
accountRepo: accountRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
txRepo: _UnusedTransactionRepository(),
|
||||
);
|
||||
});
|
||||
|
||||
test('seedForNewUser не создаёт счета и не трогает умолчательный флаг',
|
||||
() async {
|
||||
await seeder.seedForNewUser(_userId);
|
||||
|
||||
expect(accountRepo.created, isEmpty);
|
||||
expect(accountRepo.setDefaultCalls, isEmpty);
|
||||
});
|
||||
|
||||
test('seedForNewUser сидит категории обоих типов, но не транзакции', () async {
|
||||
await seeder.seedForNewUser(_userId);
|
||||
|
||||
// Базовые категории обоих типов; счетов и транзакций нет.
|
||||
expect(categoryRepo.created, isNotEmpty);
|
||||
expect(
|
||||
categoryRepo.created.map((c) => c.type).toSet(),
|
||||
{CategoryType.expense, CategoryType.income},
|
||||
);
|
||||
// Транзакций нет — _UnusedTransactionRepository бросил бы StateError.
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user