Files
OnBudget/test/features/notification_parsing/presentation/inbox_card_test.dart
T
SandersandClaude Opus 4.8 4f99b80169 Replace account_bindings with per-app default account; build analytics charts
Notification parsing:
- Drop the account_bindings table/DAO/repo/entity/controller/screen; account
  resolution now goes senderToAccount rule -> source_apps.defaultAccountId
  (trusted) -> global default (untrusted -> Inbox), via v1->v2 migration.
- Add defaultAccountId to source_apps; per-app settings consolidated into
  source_app_detail_screen (/settings/parsing/apps/:pkg).
- Inbox auto-learns an app default on first Confirm/CreateRule; account picker
  on the card instead of a disabled button; parse_error_labels extracted.

Analytics:
- Replace placeholder screen with fl_chart cards (chart_card, chart_theme,
  month_stepper, month_math domain helper); slim down habit_analysis_screen.

Android: add launcher icon (adaptive foreground + colors.xml) and app_name.

Tests: migration_v2, analytics (screen/month_math), AI retry, inbox
visibility; update resolver/gate/inbox suites for the new resolution path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:09:12 +03:00

595 lines
21 KiB
Dart

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/ai_providers.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';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
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);
class FakeInboxController extends InboxController {
int createRuleCalls = 0;
int confirmOnceCalls = 0;
int ignoreCalls = 0;
int retryCalls = 0;
String? lastCreateRuleCategory;
String? lastCreateRuleAccount;
String? lastConfirmCategory;
String? lastConfirmAccount;
final List<(String, String)> appliedCalls = [];
@override
AsyncValue<void> build() => const AsyncData(null);
/// Что возвращать из createRule/confirmOnce: true = «дефолт приложения
/// только что выучен» → карточка показывает SnackBar.
bool learnResult = false;
@override
Future<bool> createRule({
required String userId,
required RawMessage message,
required ParseDraft draft,
required String accountId,
String? categoryId,
required String merchantCanonical,
required String pattern,
MatchMode matchMode = MatchMode.contains,
bool learnAppDefault = true,
}) async {
createRuleCalls++;
lastCreateRuleCategory = categoryId;
lastCreateRuleAccount = accountId;
return learnResult;
}
@override
Future<bool> confirmOnce({
required String userId,
required RawMessage message,
required ParseDraft draft,
required String accountId,
String? categoryId,
bool learnAppDefault = true,
}) async {
confirmOnceCalls++;
lastConfirmCategory = categoryId;
lastConfirmAccount = accountId;
return learnResult;
}
@override
Future<void> markApplied(RawMessage message, String transactionId) async {
appliedCalls.add((message.id, transactionId));
}
@override
Future<void> ignore(RawMessage message) async {
ignoreCalls++;
}
@override
Future<void> retry(RawMessage message) async {
retryCalls++;
}
}
/// Сообщение, застрявшее в «ждёт сети» (pendingAi): draftJson ещё нет.
RawMessage _pendingAiMessage({int attempts = 0, String? lastParseError}) =>
RawMessage(
id: 'msg3',
userId: 'u1',
packageName: 'ru.sberbankmobile',
body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
receivedAt: _now,
dedupHash: 'h3',
status: RawMessageStatus.pendingAi,
parseAttemptCount: attempts,
lastParseError: lastParseError,
createdAt: _now,
);
RawMessage _recognizedMessage() {
final draft = ParseDraft(
rawMessageId: 'msg1',
type: TransactionType.expense,
amount: 124000,
cardLast4: '3456',
merchantRaw: 'PYATEROCHKA',
merchantCanonical: 'PYATEROCHKA',
categoryId: 'cat1',
kind: TxKind.purchase,
source: ParseSource.regex,
);
// Знакомый мерчант → есть suggestion → карточка предлагает правило.
const sugg = RuleSuggestion(
merchantRaw: 'PYATEROCHKA',
merchantCanonical: 'PYATEROCHKA',
categoryId: 'cat1',
categoryName: 'Продукты',
);
return RawMessage(
id: 'msg1',
userId: 'u1',
packageName: 'ru.sberbankmobile',
body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
receivedAt: _now,
dedupHash: 'h',
status: RawMessageStatus.inbox,
draftJson: encodeDraftBundle(draft, sugg),
createdAt: _now,
);
}
/// Уведомление без мерчанта (только сумма) → нет suggestion → карточка
/// показывает «Подтвердить» без кнопки «Создать правило».
/// [categorySuggestion] — AI-подсказка категории (по имени), которую карточка
/// должна предзаполнить в пикере.
RawMessage _amountOnlyMessage({String? categorySuggestion}) {
final draft = ParseDraft(
rawMessageId: 'msg2',
type: TransactionType.expense,
amount: 50000,
kind: TxKind.purchase,
categorySuggestion: categorySuggestion,
source: ParseSource.ai,
);
return RawMessage(
id: 'msg2',
userId: 'u1',
packageName: 'com.example.wallet',
body: 'Списание 500 ₽',
receivedAt: _now,
dedupHash: 'h2',
status: RawMessageStatus.inbox,
draftJson: encodeDraftBundle(draft, null),
createdAt: _now,
);
}
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: [
..._baseOverrides(fake, topCategories: topCategories),
...extraOverrides,
],
child: MaterialApp(
theme: AppTheme.light(),
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: InboxCard(
message: message,
userId: 'u1',
defaultAccountId: defaultAccountId,
categoryById: _categoryById,
),
),
),
);
}
void main() {
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
});
late FakeInboxController fake;
setUp(() => fake = FakeInboxController());
testWidgets('renders merchant, category and amount', (tester) async {
await tester.pumpWidget(_host(fake, _recognizedMessage()));
await tester.pump();
expect(find.text('PYATEROCHKA'), findsOneWidget);
expect(find.textContaining('Продукты'), findsWidgets);
expect(find.textContaining('240'), findsWidgets); // money formatted
});
testWidgets('tap "create rule" calls controller with category', (tester) async {
await tester.pumpWidget(_host(fake, _recognizedMessage()));
await tester.pump();
// Главная часть кнопки = ярлык «Правило: … → …». Категория задана →
// createRule вызывается напрямую, без открытия редактора.
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
await tester.pump();
expect(fake.createRuleCalls, 1);
expect(fake.lastCreateRuleCategory, 'cat1');
});
testWidgets('tap confirm calls controller', (tester) async {
await tester.pumpWidget(_host(fake, _recognizedMessage()));
await tester.pump();
await tester.tap(find.byIcon(Icons.check));
await tester.pump();
expect(fake.confirmOnceCalls, 1);
expect(fake.createRuleCalls, 0);
});
testWidgets('confirm показывает SnackBar, когда контроллер выучил дефолт',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
fake.learnResult = true;
await tester.pumpWidget(_host(fake, _recognizedMessage()));
await tester.pump();
await tester.tap(find.byIcon(Icons.check));
await tester.pump();
expect(
find.descendant(
of: find.byType(SnackBar),
matching: find.text(l10n.inboxAppDefaultSet),
),
findsOneWidget,
);
});
testWidgets('confirm без обучения дефолта — SnackBar не показывается',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(fake, _recognizedMessage()));
await tester.pump();
await tester.tap(find.byIcon(Icons.check));
await tester.pump();
expect(find.text(l10n.inboxAppDefaultSet), findsNothing);
});
testWidgets('tap ignore calls controller', (tester) async {
await tester.pumpWidget(_host(fake, _recognizedMessage()));
await tester.pump();
await tester.tap(find.byIcon(Icons.close));
await tester.pump();
expect(fake.ignoreCalls, 1);
});
testWidgets('amount-only message offers confirm, not a rule', (tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(fake, _amountOnlyMessage()));
await tester.pump();
// Нет мерчанта → кнопки «Создать правило» нет, есть «Подтвердить».
expect(find.textContaining('Создать правило'), 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, 0);
});
testWidgets('confirm-once pre-fills category from AI hint and passes it',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(
_host(fake, _amountOnlyMessage(categorySuggestion: 'Продукты')));
await tester.pump();
// AI-подсказка «Продукты» сматчена на cat1 и показана в пикере.
expect(find.text('Продукты'), findsOneWidget);
// Категория есть → подсказки «выберите категорию» нет.
expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing);
await tester.tap(find.text(l10n.inboxConfirm));
await tester.pump();
expect(fake.confirmOnceCalls, 1);
expect(fake.lastConfirmCategory, 'cat1');
});
testWidgets('category chip selects category and is passed to confirmOnce',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(
fake,
_amountOnlyMessage(),
topCategories: const ['cat1'],
));
await tester.pump();
// Чип «Продукты» виден; категория ещё не выбрана.
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, 1);
expect(fake.lastConfirmCategory, 'cat1');
});
testWidgets('create rule 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 → «Создать правило»
// открывает пикер счёта вместо молчаливого 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();
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
await tester.pumpAndSettle();
// Открылся пикер счёта, правило ещё не создано.
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();
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 {
final msg = _recognizedMessage().copyWith(draftJson: null);
await tester.pumpWidget(_host(fake, msg));
await tester.pump();
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
expect(find.text(l10n.inboxUnrecognized), findsOneWidget);
expect(find.text(l10n.inboxAddManually), findsOneWidget);
});
testWidgets('pendingAi offline: причина «нет сети», ретрай зовёт контроллер',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(
fake,
_pendingAiMessage(lastParseError: 'offline'),
extraOverrides: [
// connectivity_plus не работает под flutter test — оверрайдим явно.
isOnlineProvider.overrideWith((ref) => Stream.value(false)),
],
));
await tester.pump();
expect(find.text(l10n.inboxWaitingNetworkTitle), findsOneWidget);
expect(find.text(l10n.inboxStuckOffline), findsOneWidget);
await tester.tap(find.byIcon(Icons.refresh));
await tester.pump();
expect(fake.retryCalls, 1);
});
testWidgets('pendingAi при живой сети: причина «запрос не прошёл» + попытки',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(
fake,
_pendingAiMessage(attempts: 2, lastParseError: 'network'),
extraOverrides: [
isOnlineProvider.overrideWith((ref) => Stream.value(true)),
],
));
await tester.pump();
expect(find.text(l10n.inboxWaitingNetworkTitle), findsOneWidget);
expect(find.text(l10n.inboxStuckNetwork), findsOneWidget);
expect(find.text(l10n.parsingDetailAttempt(2, 5)), findsOneWidget);
});
}