fix tests

This commit is contained in:
2026-06-01 00:55:38 +03:00
parent 5dab1bd418
commit 7d7036c67e
18 changed files with 1550 additions and 401 deletions
+14 -1
View File
@@ -9,7 +9,7 @@ flutter gen-l10n # regenerate localiza
flutter analyze
flutter run # Android emulator
flutter test
flutter test test/path/to/file_test.dart -p vm # single test file
flutter test test/path/to/file_test.dart # single test file (no -p flag — flutter test rejects it)
```
> Run `build_runner` whenever you touch any `.dart` file that has `@riverpod`, `@DriftDatabase`,
@@ -152,6 +152,19 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe
- Access the container inside a widget test via
`ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate
notifier state after `pumpWidget`.
- **`ParsingWorker` won't drain on `container.read` alone.** Its input
`pendingMessagesProvider` is autoDispose and only subscribes to the Drift stream when it
has a *direct* listener (in-app that's `HomeScreen`). In a bare `ProviderContainer` add
`container.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true)`
alongside reading the worker, or pending messages stay stuck at `pending`.
- **Integration tests hitting real OpenRouter** live in
`test/features/notification_parsing/integration/`, tagged `@Tags(['integration'])`. Run
with `flutter test ... --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-...`
(key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays
green/offline). Override `aiKeyStoreProvider` with a fake key store + `isOnlineProvider`
with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert
the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. AI-sourced
drafts never auto-apply (amount confidence ≤ 60 < strictness 85) — expect `inbox`.
## What's left (priority order)
+9
View File
@@ -0,0 +1,9 @@
# Registers custom test tags so `flutter test --tags integration` does not warn.
#
# The `integration` tag marks tests that hit real external services (e.g. the
# OpenRouter API) and consume API tokens. They are excluded from a default
# `flutter test` run and are opt-in via `--tags integration`.
tags:
integration:
# Network-bound; allow extra time per test.
timeout: 2x
+321
View File
@@ -0,0 +1,321 @@
# План: анализ привычек трат (две шкалы оценки транзакций)
## Контекст
Добавляем механику «трекера привычек»: каждую транзакцию можно оценить по двум независимым
шкалам, а затем анализировать структуру трат по этим шкалам на отдельном подэкране аналитики.
Шкалы (обе **опциональные**, по умолчанию «не отмечено»):
1. **Обязательность** (`obligation`): `required` (Обязательно), `optional` (Не обязательно),
`unnecessary` (Не нужно).
2. **Импульсивность** (`impulse`): `impulsive` (Импульсивно), `considered` (Обдуманно).
Вся фича прячется за тумблером в настройках профиля
(`habitTrackingEnabled`). Когда выключено — БД-поля остаются, но UI (ярлыки в строках,
селекторы в форме, подэкран аналитики) не показывается. Это значит: **миграция и доменные
поля делаются всегда**, а презентация — условная.
Соответствие архитектуре (см. CLAUDE.md): `data` (Drift) → `domain` (entity/enum) →
`application` (провайдеры) → `presentation` (виджеты). Цвета — только через тему, без хардкода.
---
## Часть 0. Данные и доменка (фундамент, нужен всегда)
### 0.1 Enum'ы шкал
[enum_converters.dart](../lib/src/core/database/converters/enum_converters.dart) — добавить рядом
с существующими enum'ами + **nullable** TypeConverter'ы (поля опциональны):
```dart
enum SpendingObligation { required, optional, unnecessary }
class SpendingObligationConverter extends TypeConverter<SpendingObligation, String> {
const SpendingObligationConverter();
@override
SpendingObligation fromSql(String fromDb) =>
SpendingObligation.values.firstWhere((e) => e.name == fromDb);
@override
String toSql(SpendingObligation value) => value.name;
}
enum SpendingImpulse { impulsive, considered }
// + SpendingImpulseConverter по тому же шаблону
```
> Drift применяет `.map(converter)` к **не**-nullable колонке; для nullable-колонки конвертер
> вызывается только для не-null значений (`.nullable()` ставится после `.map(...)`).
### 0.2 Колонки транзакции
[transactions_table.dart](../lib/src/core/database/tables/transactions_table.dart) — после
`extraInfo`, до полей парсинга:
```dart
/// Шкала «обязательность» (habit-tracking). null = не отмечено.
TextColumn get obligation =>
text().map(const SpendingObligationConverter()).nullable()();
/// Шкала «импульсивность» (habit-tracking). null = не отмечено.
TextColumn get impulse =>
text().map(const SpendingImpulseConverter()).nullable()();
```
### 0.3 Тумблер в настройках
[settings_table.dart](../lib/src/core/database/tables/settings_table.dart):
```dart
BoolColumn get habitTrackingEnabled =>
boolean().withDefault(const Constant(false))();
```
### 0.4 Миграция
[app_database.dart](../lib/src/core/database/app_database.dart): `schemaVersion` **5 → 6**,
новый блок в `onUpgrade`:
```dart
if (from < 6) {
await m.addColumn(transactionsTable, transactionsTable.obligation);
await m.addColumn(transactionsTable, transactionsTable.impulse);
await m.addColumn(settingsTable, settingsTable.habitTrackingEnabled);
}
```
### 0.5 Доменные сущности + мапперы + черновик
- [transaction.dart](../lib/src/features/transactions/domain/entities/transaction.dart) (freezed):
добавить `SpendingObligation? obligation` и `SpendingImpulse? impulse`.
- [transaction_mapper.dart](../lib/src/features/transactions/data/mappers/transaction_mapper.dart):
пробросить `obligation: obligation, impulse: impulse`.
- [transaction_repository.dart](../lib/src/features/transactions/domain/repositories/transaction_repository.dart)
+ [transaction_repository_impl.dart](../lib/src/features/transactions/data/repositories/transaction_repository_impl.dart):
добавить параметры `obligation`/`impulse` в `create(...)` и прокинуть в companion
(`update(Transaction)` уже принимает сущность целиком — нужно лишь добавить поля в companion-мапинг).
- [transactions_controller.dart](../lib/src/features/transactions/application/transactions_controller.dart):
добавить `obligation`/`impulse` в сигнатуру `createTransaction(...)` и проброс в репозиторий.
- [transaction_draft.dart](../lib/src/features/transactions/presentation/state/transaction_draft.dart):
два новых nullable-поля + сеттеры `setObligation`/`setImpulse` (использовать тот же
`_sentinel`-паттерн в `copyWith`, чтобы можно было сбрасывать в null).
- [settings.dart](../lib/src/features/settings/domain/entities/settings.dart) +
[settings_mapper.dart](../lib/src/features/settings/data/mappers/settings_mapper.dart):
добавить `bool habitTrackingEnabled`.
- Settings repo/controller: метод `setHabitTrackingEnabled(userId, bool)` по образцу
`updateThemeMode` ([settings_repository_impl.dart](../lib/src/features/settings/data/repositories/settings_repository_impl.dart),
[settings_controller.dart](../lib/src/features/settings/application/settings_controller.dart));
не забыть `habitTrackingEnabled` в `_toCompanion`, `_defaultSettings` (= `false`) и `ensureDefaults`.
### 0.6 Code-gen
После правок: `dart run build_runner build --delete-conflicting-outputs`
(затрагивает `app_database.g.dart`, `transaction.freezed.dart`, `settings.freezed.dart`,
`transaction_draft.g.dart`). Затем `flutter analyze`.
---
## Часть 1. Ярлыки в строке транзакции (presentation)
### 1.1 Цвета ярлыков
Семантические цвета шкал (тёмно-зелёный/светло-зелёный и т.д.) **не относятся к брендовой
палитре** `AppPalette`, но хардкод в виджетах запрещён. Решение: отдельный
`ThemeExtension` рядом с палитрой.
Новый файл `lib/src/app/theme/habit_chip_colors.dart``HabitChipColors extends
ThemeExtension<HabitChipColors>` с парами фон/текст под каждое значение + светлый/тёмный
варианты, по образцу [app_colors.dart](../lib/src/app/theme/app_colors.dart) (`copyWith`/`lerp`/
`extension ... on BuildContext`). Зарегистрировать в `ThemeData.extensions` в
[app_theme.dart](../lib/src/app/theme/app_theme.dart) рядом с `AppPalette`.
Токены (тёмная тема как база; для светлой подобрать читаемые аналоги):
| Значение | Фон | Текст/иконка |
|---|---|---|
| `required` (Обязательно) | тёмно-зелёный | светло-зелёный |
| `optional` (Не обязательно) | тёмно-горчичный / коричнево-оранжевый | песочный |
| `unnecessary` (Не нужно) | тёмно-бордовый | бледно-розовый |
| `impulsive` (⚡ Импульс) | тёмно-коричневый / полупрозрачный оранжевый | светло-оранжевый |
`considered` (Обдуманно) ярлыком **не** показывается (чтобы не перегружать список).
### 1.2 Виджет ярлыков
Новый `lib/src/features/home/presentation/widgets/habit_chips.dart`:
- `HabitChips({required Transaction tx})``Row` с выравниванием по левому краю.
- Каждый ярлык — «пилюля»: `Container` с `BorderRadius.circular(99)`,
`padding: EdgeInsets.symmetric(horizontal: 6, vertical: 1)`, `fontSize: 11`
(как у времени в [tx_row.dart](../lib/src/features/home/presentation/widgets/tx_row.dart)),
`fontWeight: FontWeight.w600`.
- Порядок: сначала ярлык обязательности (если задан), затем «⚡ Импульс» (только при
`impulse == impulsive`); между ними `SizedBox(width: 6)`.
- «⚡ Импульс» = `Icon(Icons.bolt, size: 11)` слева + текст.
- Пустое состояние (обе шкалы null) → текст «не отмечено», `fontStyle: italic`,
без фона, цвет `p.ink2`.
- Локализация всех подписей через `context.l10n` (см. §3).
### 1.3 Интеграция в строку
В [tx_row.dart](../lib/src/features/home/presentation/widgets/tx_row.dart) фича-флаг приходит
параметром (виджет — `StatelessWidget`, без `ref`): добавить
`final bool habitTrackingEnabled;` в конструктор `TxRow`.
Логика подвала строки (сейчас `subtitle = '${cat?.name} · ${time}'` на 3-й строке, плюс
опциональная строка `extraInfo`):
- **Флаг выключен** → поведение как сейчас.
- **Флаг включён** → блок `extraInfo` **заменяется** строкой привычек: время + `·` + ярлыки.
То есть нижняя строка = `Row[ Text(time), Text(' · '), HabitChips(tx) ]` с выравниванием
по левому краю. (Категория остаётся в `subtitle`-строке как сейчас, либо переносится — см.
«Открытый вопрос» ниже; базовый вариант: ярлыки идут вместо `extraInfo`, как просил юзер
«Вместо доп. информации».)
Источник флага: тот, кто строит `TxRow`
([transactions_section.dart](../lib/src/features/home/presentation/widgets/transactions_section.dart)
и аналитический список §2) читает
`ref.watch(settingsControllerProvider(userId)).value?.habitTrackingEnabled ?? false`
и прокидывает в `TxRow`.
### 1.4 Селекторы в форме транзакции
[transaction_form_screen.dart](../lib/src/features/transactions/presentation/screens/transaction_form_screen.dart):
при включённом флаге показать два селектора (под существующими полями):
- **Обязательность** — три chip'а в ряд (по образцу `type_segmented.dart` /
фильтр-chip'ов), повторное нажатие на активный сбрасывает в null.
- **Импульсивность** — segmented на два значения (Импульсивно/Обдуманно), также сбрасываемый.
Связать с `TransactionDraftController.setObligation/setImpulse`; прокинуть значения в
`createTransaction(...)` / `updateTransaction(...)` при сохранении. При hydrate из существующей
транзакции (режим редактирования) — заполнить из сущности.
---
## Часть 2. Подэкран «Анализ привычек» в аналитике
### 2.1 Аналитика как хаб
Сейчас [analytics_screen.dart](../lib/src/features/analytics/presentation/screens/analytics_screen.dart) —
заглушка. Превращаем в список подэкранов (на будущее их несколько); первый —
**«Анализ привычек»** (показывать пункт только при `habitTrackingEnabled`).
Навигация:
- [app_routes.dart](../lib/src/app/router/app_routes.dart): `static const habitAnalysis = '/analytics/habits';`
- [app_router.dart](../lib/src/app/router/app_router.dart): `GoRoute` внутри ветки analytics
(или push поверх) → новый экран.
Новый файл `lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart`
(`ConsumerWidget`).
### 2.2 Шапка + выбор месяца
- Крупный заголовок «Транзакции» слева (белый/`p.ink`).
- Селектор месяца справа/над заголовком: «Май 2026» + стрелки ` `, переиспользуя
существующий `selectedMonthProvider` (`.previous()` / `.next()`) из
[selected_category_filter.dart](../lib/src/features/home/presentation/state/selected_category_filter.dart)
и формат `DateFormat('LLLL yyyy', locale)`. Цвет текста — `p.ink`/`p.ink2`.
> Месяц общий с главным экраном (тот же провайдер) — это ок и даже желательно
> (консистентный период). Если нужна независимость — завести отдельный
> `habitSelectedMonthProvider`; **решить до реализации** (см. открытые вопросы).
### 2.3 Провайдеры агрегатов
Новый файл `lib/src/features/analytics/application/habit_analysis_providers.dart`
(`@riverpod`), по образцу [month_summary.dart](../lib/src/features/home/presentation/month_summary.dart):
- Состояние фильтров (autoDispose `@riverpod class`):
- `HabitImpulseFilter``SpendingImpulse?` + спец-значение «Все» (null = все).
- `HabitObligationFilter``Set<SpendingObligation>` (мульти-выбор chip'ов; пусто = все).
- `habitMonthTransactions(userId)` — транзакции за `selectedMonth`, **только расходы**
(`type == expense`), без учёта фильтров (нужны для подсчёта сумм в каждой секции).
- `habitSumByImpulse(userId)``Map<SpendingImpulse?, int>` (включая ключ null = «без оценки»;
для секции «Все» — общая сумма).
- `habitSumByObligation(userId)``Map<SpendingObligation?, int>`.
- `habitFilteredTransactions(userId)` — применяет оба фильтра к `habitMonthTransactions`
(для сводки и списка).
Суммы — в минорных единицах; форматирование через `MoneyText`
([money_text.dart](../lib/src/features/home/presentation/widgets/money_text.dart)).
### 2.4 Блок фильтров (с суммами)
Над контролами — подзаголовки мелким шрифтом, ALL CAPS, цвет `p.ink2`:
«ИМПУЛЬСИВНОСТЬ», «ОБЯЗАТЕЛЬНОСТЬ».
**Импульсивность — segmented control** (3 равные секции, контент в 2 строки, текст по центру):
| Секция | Строка 1 | Строка 2 |
|---|---|---|
| Все | Все | сумма |
| Обдуманные | Обдуманные | сумма |
| Импульс | ⚡ Импульс | сумма |
Активный сегмент — фон `p.ink2`/тёмно-серый + белый текст; неактивные сливаются с фоном
(паттерн `type_segmented.dart`).
**Обязательность — набор chip'ов** (3 кнопки в ряд, тонкая обводка `p.line`, цветная
точка-индикатор слева, контент в 2 строки):
| Chip | Точка | Строка 1 | Строка 2 |
|---|---|---|---|
| Обязательно | 🟢 | Обязательно | сумма |
| Можно без | 🟡 | Можно без | сумма |
| Не нужно | 🔴 | Не нужно | сумма |
Цвета точек — из `HabitChipColors` (§1.1). Нажатие тоглит значение в `HabitObligationFilter`.
> Подписи фильтра отличаются от ярлыков строки: здесь «Можно без» вместо «Не обязательно» —
> это отдельные l10n-ключи (§3).
### 2.5 Сводка + список
- Сводная строка под фильтрами: слева количество («10 операций» — переиспользовать
`l10n.transactionCount(n)`), справа крупная сумма («−40 592 ₽», `MoneyText` крупным шрифтом)
по `habitFilteredTransactions`.
- Ниже — скроллируемый список за выбранный месяц, отфильтрованный, **дизайн идентичен
главному экрану**: переиспользовать `TxRow` (с `habitTrackingEnabled: true`, чтобы ярлыки
были видны) и группировку по дням (`DayHeader`).
---
## Часть 3. Локализация
[app_en.arb](../lib/l10n/app_en.arb) / [app_ru.arb](../lib/l10n/app_ru.arb) → `flutter gen-l10n`.
Новые ключи (RU значения):
| Ключ | RU |
|---|---|
| `habitTrackingTile` | «Анализ привычек трат» (тумблер в профиле) |
| `habitObligationRequired` | «Обязательно» |
| `habitObligationOptional` | «Не обязательно» |
| `habitObligationUnnecessary` | «Не нужно» |
| `habitObligationOptionalShort` | «Можно без» (для фильтра аналитики) |
| `habitImpulseImpulsive` | «Импульс» |
| `habitImpulseConsidered` | «Обдуманные» |
| `habitNotMarked` | «не отмечено» |
| `habitFilterAll` | «Все» |
| `habitScaleImpulseCaps` | «ИМПУЛЬСИВНОСТЬ» |
| `habitScaleObligationCaps` | «ОБЯЗАТЕЛЬНОСТЬ» |
| `habitAnalysisTitle` | «Транзакции» (заголовок подэкрана) |
| `habitAnalysisTile` | «Анализ привычек» (пункт в хабе аналитики) |
---
## Часть 4. Тесты (см. соглашения в CLAUDE.md → Testing)
- **Миграция v5→v6**: тест на `AppDatabase.forTesting` — apply миграции, проверить наличие
колонок и дефолтов.
- **Repo/controller**: `create`/`update` с `obligation`/`impulse` (round-trip через FakeRepo и
через реальную in-memory БД).
- **Агрегаты**: `habitSumByObligation`/`habitSumByImpulse`/`habitFilteredTransactions`
`ProviderContainer` с override стрима транзакций, проверить суммы и фильтрацию (вкл. ключ null).
- **Widget**: `HabitChips` — рендер пилюль по значениям, пустое состояние «не отмечено»;
`TxRow` с `habitTrackingEnabled` true/false (ярлыки vs `extraInfo`).
- `import 'package:drift/drift.dart' hide isNull, isNotNull;` в БД-тестах.
---
## Порядок реализации
1. **Часть 0** целиком (data/domain/migration) + `build_runner` + `flutter analyze` — фундамент.
2. Тумблер в профиле ([profile_screen.dart](../lib/src/features/profile/presentation/screens/profile_screen.dart),
`_Row` со `Switch`, как у тёмной темы) → `settingsController.setHabitTrackingEnabled`.
3. `HabitChipColors` + `HabitChips` + интеграция в `TxRow` и `transactions_section`.
4. Селекторы в форме транзакции.
5. Подэкран «Анализ привычек» (провайдеры → шапка → фильтры → сводка → список).
6. Локализация (по ходу 2–5).
7. Тесты.
## Открытые вопросы (решить до реализации)
- **Подвал строки**: ярлыки *заменяют* `extraInfo` (как написано в ТЗ), но что с категорией +
временем? Базовый план: ярлыки идут вместо строки `extraInfo`, строка `категория · время`
остаётся. Альтернатива: время уходит в строку ярлыков (`время · ярлыки`), категория — в
основную. Уточнить визуал.
- **Месяц в аналитике**: общий `selectedMonthProvider` с главным экраном или отдельный.
- **Палитра habit-цветов**: точные hex'ы для светлой темы (в ТЗ описаны «тёмные» фоны —
они под тёмную тему; для светлой нужны читаемые аналоги).
- **`required` как имя enum-значения** — зарезервированное слово Dart можно использовать как
имя поля enum, но если возникнут конфликты, переименовать в `mandatory`.
+40 -3
View File
@@ -98,10 +98,10 @@
"txDateTimeLabel": "Date & time",
"txBalanceAfter": "Balance after",
"txTransferAfter": "After transfer",
"txNoteLabel": "Note",
"txNoteHint": "e.g. Groceries for the week…",
"txNoteLabel": "Merchant",
"txNoteHint": "e.g. Amazon…",
"txExtraInfoLabel": "Additional info",
"txExtraInfoHint": "e.g. receipt #, URL, reference…",
"txExtraInfoHint": "e.g. Groceries for the week…",
"txSaveButton": "Add transaction",
"txSaveEditButton": "Save changes",
"txTransferButton": "Transfer {amount}",
@@ -236,6 +236,43 @@
"parsingStatusPendingAi": "Waiting for AI",
"parsingStatusParsedPartial": "Partial",
"parsingLogOnline": "Online",
"parsingLogOffline": "Offline",
"parsingLogFilterWaiting": "Waiting",
"parsingDetailConfidence": "Confidence",
"parsingDetailPipeline": "Pipeline",
"parsingDetailMessage": "Message",
"parsingDetailDraft": "Draft",
"parsingDetailRule": "Rule suggestion",
"parsingDetailMeta": "Meta",
"parsingDetailAttempt": "Attempt {count}/{max}",
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
"parsingDetailSource": "Source",
"parsingDetailAmountScore": "Amount",
"parsingDetailAccountScore": "Account",
"parsingDetailTypeScore": "Type",
"parsingDetailMerchantScore": "Merchant",
"parsingDetailCategoryScore": "Category",
"parsingDetailTitle": "Title",
"parsingDetailBody": "Body",
"parsingDetailCard": "Card",
"parsingDetailCurrency": "Currency",
"parsingDetailKind": "Kind",
"parsingDetailDate": "Date",
"parsingDetailMerchantRaw": "Merchant (raw)",
"parsingDetailMerchantCanonical": "Merchant (canonical)",
"parsingDetailCounterparty": "Counterparty",
"parsingDetailCategorySuggestion": "Category hint",
"parsingDetailAccountId": "Account ID",
"parsingDetailCategoryId": "Category ID",
"parsingDetailTransferTo": "Transfer to",
"parsingDetailRuleCategory": "Category",
"parsingDetailTransactionId": "Transaction ID",
"parsingDetailDedupHash": "Dedup hash",
"parsingDetailReceivedAt": "Received at",
"parsingDetailCreatedAt": "Created at",
"inboxTitle": "From notifications",
"inboxSubtitle": "A rule is learned on the first tap: similar messages will be confirmed automatically.",
"inboxEmpty": "Inbox is empty. New notifications will appear here.",
+207 -3
View File
@@ -389,13 +389,13 @@ abstract class AppLocalizations {
/// No description provided for @txNoteLabel.
///
/// In ru, this message translates to:
/// **'Заметка'**
/// **'Мерчант'**
String get txNoteLabel;
/// No description provided for @txNoteHint.
///
/// In ru, this message translates to:
/// **'Например, продукты на неделю…'**
/// **'Например, Пятерочка…'**
String get txNoteHint;
/// No description provided for @txExtraInfoLabel.
@@ -407,7 +407,7 @@ abstract class AppLocalizations {
/// No description provided for @txExtraInfoHint.
///
/// In ru, this message translates to:
/// **'Например, номер чека, ссылка…'**
/// **'Например, продукты на неделю…'**
String get txExtraInfoHint;
/// No description provided for @txSaveButton.
@@ -1070,6 +1070,210 @@ abstract class AppLocalizations {
/// **'Частично'**
String get parsingStatusParsedPartial;
/// No description provided for @parsingLogOnline.
///
/// In ru, this message translates to:
/// **'Онлайн'**
String get parsingLogOnline;
/// No description provided for @parsingLogOffline.
///
/// In ru, this message translates to:
/// **'Офлайн'**
String get parsingLogOffline;
/// No description provided for @parsingLogFilterWaiting.
///
/// In ru, this message translates to:
/// **'Ждёт сети'**
String get parsingLogFilterWaiting;
/// No description provided for @parsingDetailConfidence.
///
/// In ru, this message translates to:
/// **'Уверенность'**
String get parsingDetailConfidence;
/// No description provided for @parsingDetailPipeline.
///
/// In ru, this message translates to:
/// **'Pipeline'**
String get parsingDetailPipeline;
/// No description provided for @parsingDetailMessage.
///
/// In ru, this message translates to:
/// **'Сообщение'**
String get parsingDetailMessage;
/// No description provided for @parsingDetailDraft.
///
/// In ru, this message translates to:
/// **'Черновик'**
String get parsingDetailDraft;
/// No description provided for @parsingDetailRule.
///
/// In ru, this message translates to:
/// **'Предложение правила'**
String get parsingDetailRule;
/// No description provided for @parsingDetailMeta.
///
/// In ru, this message translates to:
/// **'Мета'**
String get parsingDetailMeta;
/// No description provided for @parsingDetailAttempt.
///
/// In ru, this message translates to:
/// **'Попытка {count}/{max}'**
String parsingDetailAttempt(int count, int max);
/// No description provided for @parsingDetailSource.
///
/// In ru, this message translates to:
/// **'Источник'**
String get parsingDetailSource;
/// No description provided for @parsingDetailAmountScore.
///
/// In ru, this message translates to:
/// **'Сумма'**
String get parsingDetailAmountScore;
/// No description provided for @parsingDetailAccountScore.
///
/// In ru, this message translates to:
/// **'Счёт'**
String get parsingDetailAccountScore;
/// No description provided for @parsingDetailTypeScore.
///
/// In ru, this message translates to:
/// **'Тип'**
String get parsingDetailTypeScore;
/// No description provided for @parsingDetailMerchantScore.
///
/// In ru, this message translates to:
/// **'Мерчант'**
String get parsingDetailMerchantScore;
/// No description provided for @parsingDetailCategoryScore.
///
/// In ru, this message translates to:
/// **'Категория'**
String get parsingDetailCategoryScore;
/// No description provided for @parsingDetailTitle.
///
/// In ru, this message translates to:
/// **'Заголовок'**
String get parsingDetailTitle;
/// No description provided for @parsingDetailBody.
///
/// In ru, this message translates to:
/// **'Текст'**
String get parsingDetailBody;
/// No description provided for @parsingDetailCard.
///
/// In ru, this message translates to:
/// **'Карта'**
String get parsingDetailCard;
/// No description provided for @parsingDetailCurrency.
///
/// In ru, this message translates to:
/// **'Валюта'**
String get parsingDetailCurrency;
/// No description provided for @parsingDetailKind.
///
/// In ru, this message translates to:
/// **'Вид'**
String get parsingDetailKind;
/// No description provided for @parsingDetailDate.
///
/// In ru, this message translates to:
/// **'Дата'**
String get parsingDetailDate;
/// No description provided for @parsingDetailMerchantRaw.
///
/// In ru, this message translates to:
/// **'Мерчант (сырой)'**
String get parsingDetailMerchantRaw;
/// No description provided for @parsingDetailMerchantCanonical.
///
/// In ru, this message translates to:
/// **'Мерчант (норм.)'**
String get parsingDetailMerchantCanonical;
/// No description provided for @parsingDetailCounterparty.
///
/// In ru, this message translates to:
/// **'Контрагент'**
String get parsingDetailCounterparty;
/// No description provided for @parsingDetailCategorySuggestion.
///
/// In ru, this message translates to:
/// **'Подсказка категории'**
String get parsingDetailCategorySuggestion;
/// No description provided for @parsingDetailAccountId.
///
/// In ru, this message translates to:
/// **'ID счёта'**
String get parsingDetailAccountId;
/// No description provided for @parsingDetailCategoryId.
///
/// In ru, this message translates to:
/// **'ID категории'**
String get parsingDetailCategoryId;
/// No description provided for @parsingDetailTransferTo.
///
/// In ru, this message translates to:
/// **'Перевод на'**
String get parsingDetailTransferTo;
/// No description provided for @parsingDetailRuleCategory.
///
/// In ru, this message translates to:
/// **'Категория'**
String get parsingDetailRuleCategory;
/// No description provided for @parsingDetailTransactionId.
///
/// In ru, this message translates to:
/// **'ID транзакции'**
String get parsingDetailTransactionId;
/// No description provided for @parsingDetailDedupHash.
///
/// In ru, this message translates to:
/// **'Dedup-хеш'**
String get parsingDetailDedupHash;
/// No description provided for @parsingDetailReceivedAt.
///
/// In ru, this message translates to:
/// **'Получено'**
String get parsingDetailReceivedAt;
/// No description provided for @parsingDetailCreatedAt.
///
/// In ru, this message translates to:
/// **'Создано'**
String get parsingDetailCreatedAt;
/// No description provided for @inboxTitle.
///
/// In ru, this message translates to:
+107 -3
View File
@@ -182,16 +182,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get txTransferAfter => 'After transfer';
@override
String get txNoteLabel => 'Note';
String get txNoteLabel => 'Merchant';
@override
String get txNoteHint => 'e.g. Groceries for the week';
String get txNoteHint => 'e.g. Amazon';
@override
String get txExtraInfoLabel => 'Additional info';
@override
String get txExtraInfoHint => 'e.g. receipt #, URL, reference';
String get txExtraInfoHint => 'e.g. Groceries for the week';
@override
String get txSaveButton => 'Add transaction';
@@ -554,6 +554,110 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get parsingStatusParsedPartial => 'Partial';
@override
String get parsingLogOnline => 'Online';
@override
String get parsingLogOffline => 'Offline';
@override
String get parsingLogFilterWaiting => 'Waiting';
@override
String get parsingDetailConfidence => 'Confidence';
@override
String get parsingDetailPipeline => 'Pipeline';
@override
String get parsingDetailMessage => 'Message';
@override
String get parsingDetailDraft => 'Draft';
@override
String get parsingDetailRule => 'Rule suggestion';
@override
String get parsingDetailMeta => 'Meta';
@override
String parsingDetailAttempt(int count, int max) {
return 'Attempt $count/$max';
}
@override
String get parsingDetailSource => 'Source';
@override
String get parsingDetailAmountScore => 'Amount';
@override
String get parsingDetailAccountScore => 'Account';
@override
String get parsingDetailTypeScore => 'Type';
@override
String get parsingDetailMerchantScore => 'Merchant';
@override
String get parsingDetailCategoryScore => 'Category';
@override
String get parsingDetailTitle => 'Title';
@override
String get parsingDetailBody => 'Body';
@override
String get parsingDetailCard => 'Card';
@override
String get parsingDetailCurrency => 'Currency';
@override
String get parsingDetailKind => 'Kind';
@override
String get parsingDetailDate => 'Date';
@override
String get parsingDetailMerchantRaw => 'Merchant (raw)';
@override
String get parsingDetailMerchantCanonical => 'Merchant (canonical)';
@override
String get parsingDetailCounterparty => 'Counterparty';
@override
String get parsingDetailCategorySuggestion => 'Category hint';
@override
String get parsingDetailAccountId => 'Account ID';
@override
String get parsingDetailCategoryId => 'Category ID';
@override
String get parsingDetailTransferTo => 'Transfer to';
@override
String get parsingDetailRuleCategory => 'Category';
@override
String get parsingDetailTransactionId => 'Transaction ID';
@override
String get parsingDetailDedupHash => 'Dedup hash';
@override
String get parsingDetailReceivedAt => 'Received at';
@override
String get parsingDetailCreatedAt => 'Created at';
@override
String get inboxTitle => 'From notifications';
+107 -3
View File
@@ -188,16 +188,16 @@ class AppLocalizationsRu extends AppLocalizations {
String get txTransferAfter => 'После перевода';
@override
String get txNoteLabel => 'Заметка';
String get txNoteLabel => 'Мерчант';
@override
String get txNoteHint => 'Например, продукты на неделю';
String get txNoteHint => 'Например, Пятерочка';
@override
String get txExtraInfoLabel => 'Доп. информация';
@override
String get txExtraInfoHint => 'Например, номер чека, ссылка';
String get txExtraInfoHint => 'Например, продукты на неделю';
@override
String get txSaveButton => 'Добавить транзакцию';
@@ -566,6 +566,110 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get parsingStatusParsedPartial => 'Частично';
@override
String get parsingLogOnline => 'Онлайн';
@override
String get parsingLogOffline => 'Офлайн';
@override
String get parsingLogFilterWaiting => 'Ждёт сети';
@override
String get parsingDetailConfidence => 'Уверенность';
@override
String get parsingDetailPipeline => 'Pipeline';
@override
String get parsingDetailMessage => 'Сообщение';
@override
String get parsingDetailDraft => 'Черновик';
@override
String get parsingDetailRule => 'Предложение правила';
@override
String get parsingDetailMeta => 'Мета';
@override
String parsingDetailAttempt(int count, int max) {
return 'Попытка $count/$max';
}
@override
String get parsingDetailSource => 'Источник';
@override
String get parsingDetailAmountScore => 'Сумма';
@override
String get parsingDetailAccountScore => 'Счёт';
@override
String get parsingDetailTypeScore => 'Тип';
@override
String get parsingDetailMerchantScore => 'Мерчант';
@override
String get parsingDetailCategoryScore => 'Категория';
@override
String get parsingDetailTitle => 'Заголовок';
@override
String get parsingDetailBody => 'Текст';
@override
String get parsingDetailCard => 'Карта';
@override
String get parsingDetailCurrency => 'Валюта';
@override
String get parsingDetailKind => 'Вид';
@override
String get parsingDetailDate => 'Дата';
@override
String get parsingDetailMerchantRaw => 'Мерчант (сырой)';
@override
String get parsingDetailMerchantCanonical => 'Мерчант (норм.)';
@override
String get parsingDetailCounterparty => 'Контрагент';
@override
String get parsingDetailCategorySuggestion => 'Подсказка категории';
@override
String get parsingDetailAccountId => 'ID счёта';
@override
String get parsingDetailCategoryId => 'ID категории';
@override
String get parsingDetailTransferTo => 'Перевод на';
@override
String get parsingDetailRuleCategory => 'Категория';
@override
String get parsingDetailTransactionId => 'ID транзакции';
@override
String get parsingDetailDedupHash => 'Dedup-хеш';
@override
String get parsingDetailReceivedAt => 'Получено';
@override
String get parsingDetailCreatedAt => 'Создано';
@override
String get inboxTitle => 'Из уведомлений';
+40 -3
View File
@@ -98,10 +98,10 @@
"txDateTimeLabel": "Дата и время",
"txBalanceAfter": "Остаток после",
"txTransferAfter": "После перевода",
"txNoteLabel": "Заметка",
"txNoteHint": "Например, продукты на неделю…",
"txNoteLabel": "Мерчант",
"txNoteHint": "Например, Пятерочка…",
"txExtraInfoLabel": "Доп. информация",
"txExtraInfoHint": "Например, номер чека, ссылка…",
"txExtraInfoHint": "Например, продукты на неделю…",
"txSaveButton": "Добавить транзакцию",
"txSaveEditButton": "Сохранить",
"txTransferButton": "Перевести {amount}",
@@ -236,6 +236,43 @@
"parsingStatusPendingAi": "Ждёт AI",
"parsingStatusParsedPartial": "Частично",
"parsingLogOnline": "Онлайн",
"parsingLogOffline": "Офлайн",
"parsingLogFilterWaiting": "Ждёт сети",
"parsingDetailConfidence": "Уверенность",
"parsingDetailPipeline": "Pipeline",
"parsingDetailMessage": "Сообщение",
"parsingDetailDraft": "Черновик",
"parsingDetailRule": "Предложение правила",
"parsingDetailMeta": "Мета",
"parsingDetailAttempt": "Попытка {count}/{max}",
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
"parsingDetailSource": "Источник",
"parsingDetailAmountScore": "Сумма",
"parsingDetailAccountScore": "Счёт",
"parsingDetailTypeScore": "Тип",
"parsingDetailMerchantScore": "Мерчант",
"parsingDetailCategoryScore": "Категория",
"parsingDetailTitle": "Заголовок",
"parsingDetailBody": "Текст",
"parsingDetailCard": "Карта",
"parsingDetailCurrency": "Валюта",
"parsingDetailKind": "Вид",
"parsingDetailDate": "Дата",
"parsingDetailMerchantRaw": "Мерчант (сырой)",
"parsingDetailMerchantCanonical": "Мерчант (норм.)",
"parsingDetailCounterparty": "Контрагент",
"parsingDetailCategorySuggestion": "Подсказка категории",
"parsingDetailAccountId": "ID счёта",
"parsingDetailCategoryId": "ID категории",
"parsingDetailTransferTo": "Перевод на",
"parsingDetailRuleCategory": "Категория",
"parsingDetailTransactionId": "ID транзакции",
"parsingDetailDedupHash": "Dedup-хеш",
"parsingDetailReceivedAt": "Получено",
"parsingDetailCreatedAt": "Создано",
"inboxTitle": "Из уведомлений",
"inboxSubtitle": "Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически.",
"inboxEmpty": "Inbox пуст. Новые уведомления появятся здесь.",
+4 -4
View File
@@ -36,8 +36,8 @@ class AppPalette extends ThemeExtension<AppPalette> {
ink2: Color(0xFF6B6B66),
line: Color(0xFFD8D5CC),
line2: Color(0xFFB8B5AC),
accent: Color(0xFF4A8A82),
accentSoft: Color(0xFFDDE9E6),
accent: Color(0xFFA68A64),
accentSoft: Color(0xFFEBE1CC),
positive: Color(0xFF6F8C69),
negative: Color(0xFFB3675A),
);
@@ -50,8 +50,8 @@ class AppPalette extends ThemeExtension<AppPalette> {
ink2: Color(0xFF8D8A83),
line: Color(0xFF2E2D2A),
line2: Color(0xFF4A4845),
accent: Color(0xFF76B3A9),
accentSoft: Color(0xFF23332F),
accent: Color(0xFFC7A878),
accentSoft: Color(0xFF2F2A1E),
positive: Color(0xFF92B58A),
negative: Color(0xFFD18D7E),
);
@@ -9,7 +9,6 @@ import '../data/parser/ai_parser.dart';
import '../data/parser/confidence_scorer.dart';
import '../data/parser/decision_gate.dart';
import '../data/parser/draft_codec.dart';
import '../data/parser/regex_parser.dart';
import '../data/parser/rule_lookup.dart';
import '../data/parser/rule_suggester.dart';
import '../domain/entities/parse_draft.dart';
@@ -29,7 +28,11 @@ Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
/// ParsingWorker (§5): слушает `raw_messages.pending` и прогоняет pipeline
/// regex → (AI fallback) → resolver → rule_lookup → suggester → scorer → gate.
/// AI → resolver → rule_lookup → suggester → scorer → gate.
///
/// Извлечение делает только AI: встроенные regex-шаблоны убраны (не было
/// видимого пользователю слоя). Без согласия/ключа/сети сообщение уходит
/// в Inbox на ручной разбор.
///
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
/// Провайдер `keepAlive` — активируется чтением из HomeScreen.
@@ -102,19 +105,13 @@ class ParsingWorker extends _$ParsingWorker {
final settings = await ref.read(parsingSettingsControllerProvider.future);
if (!settings.enabled) return; // фича выключена — оставляем pending.
// 1. Regex (этап 1).
final parsed = const RegexParser().parse(msg);
if (parsed != null) {
await _runPipeline(userId, msg, parsed, settings);
return;
}
// 2. Regex не справился → AI fallback (этап 2) или legacy-поведение.
await _handleRegexMiss(userId, msg, settings);
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
await _extractViaAi(userId, msg, settings);
}
/// Ветка «regex не распознал»: зовём AI (если разрешено), иначе Inbox/ignored.
Future<void> _handleRegexMiss(
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
/// иначе Inbox на ручной разбор.
Future<void> _extractViaAi(
String userId,
RawMessage msg,
ParsingSettings settings,
@@ -122,6 +119,13 @@ class ParsingWorker extends _$ParsingWorker {
final repo = ref.read(rawMessagesRepositoryProvider);
final settingsCtrl = ref.read(parsingSettingsControllerProvider.notifier);
// Нет цифр → это не операция (спам/реклама) → ignored, AI не тратим.
if (looksLikeNonTransaction(msg.body)) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// Есть цифры → кандидат в транзакцию.
// AI доступен только при согласии, наличии ключа и не исчерпанном лимите.
// Лимит читаем «свежим» (счётчик дневной, привязан к дате) — кешированное
// состояние контроллера могло устареть при смене суток.
@@ -130,12 +134,8 @@ class ParsingWorker extends _$ParsingWorker {
final aiParser = aiAllowed ? await ref.read(aiParserProvider.future) : null;
if (aiParser == null) {
// Legacy (Phase 1): баланс/реклама → ignored, иначе → Inbox вручную.
if (looksLikeNonTransaction(msg.body)) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
} else {
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
}
// AI недоступен → ручной разбор в Inbox.
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
return;
}
@@ -215,7 +215,7 @@ class ParsingWorker extends _$ParsingWorker {
}
}
/// Общий хвост pipeline (§5, шаги 38) для regex- и AI-draft.
/// Общий хвост pipeline (§5, шаги 38) для AI-draft.
Future<void> _runPipeline(
String userId,
RawMessage msg,
@@ -1,63 +0,0 @@
import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/bank_template.dart';
/// Встроенный набор RU-шаблонов (regex first, §6).
///
/// Шаблоны универсальны (не привязаны к конкретным банкам) и покрывают
/// распространённые форматы РФ: покупка/списание с карты, зачисление,
/// СБП-перевод по телефону, возврат. Если ни один не совпал — этап 2 (AI,
/// Phase 2); в Phase 1 это `parsed_partial` → Inbox.
///
/// Группы извлечения нумерованные (`$1`, `$2`, …) — соответствуют capture-
/// группам в [BankTemplate.pattern]. Матч регистронезависимый.
const List<BankTemplate> bankTemplatesCatalog = [
// 1. Покупка: сумма → ₽ → *last4 → мерчант (Сбер-подобный формат).
// «Покупка 1240р Карта *3456 PYATEROCHKA 14:05 Баланс: 5000р»
BankTemplate(
key: 'purchase_amount_card_merchant_ru',
pattern:
r'(?:Покупка|Оплата|Списание)\s+(\d[\d\s., ]*)\s*(?:₽|руб\.?|р\.?|RUB)[\s\S]*?\*(\d{4})\s+([^\d\n][^\n]*?)(?=\s+\d{1,2}[:.]\d{2}|\s+Баланс|\s+Доступно|[.;]|$)',
extract: {'amount': r'$1', 'cardLast4': r'$2', 'merchant': r'$3'},
type: TransactionType.expense,
),
// 2. Покупка: *last4 → сумма ₽ → мерчант (Тинькофф-подобный формат).
// «Покупка. Карта *3456. 1 240 ₽. Пятёрочка. Доступно 5 000 ₽»
BankTemplate(
key: 'purchase_card_amount_merchant_ru',
pattern:
r'(?:Покупка|Оплата|Списание)[\s\S]*?\*(\d{4})[\s\S]*?(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)\.?\s+([^\d\n][^\n.]*?)(?=[.]|\s+Доступно|\s+Баланс|$)',
extract: {'cardLast4': r'$1', 'amount': r'$2', 'merchant': r'$3'},
type: TransactionType.expense,
),
// 3. Зачисление / пополнение / поступление.
// «Зачисление 5000 ₽ Карта *3456 ...»
BankTemplate(
key: 'income_card_ru',
pattern:
r'(?:Зачисление|Пополнение|Поступление)\s+(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?\*(\d{4})',
extract: {'amount': r'$1', 'cardLast4': r'$2'},
type: TransactionType.income,
),
// 4. СБП-перевод по телефону.
// «Перевод 1000 ₽ на +79991234567 ...»
BankTemplate(
key: 'sbp_phone_ru',
pattern:
r'(?:Перевод|СБП|Отправлен[оы]?)[\s\S]*?(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?(\+7\d{10})',
extract: {'amount': r'$1', 'phone': r'$2'},
type: TransactionType.expense,
),
// 5. Возврат покупки.
// «Возврат 1240 ₽ Карта *3456 ...»
BankTemplate(
key: 'refund_card_ru',
pattern:
r'(?:Возврат|Refund)\s+(\d[\d\s., ]*)\s*(?:₽|руб\.?|RUB)[\s\S]*?\*(\d{4})',
extract: {'amount': r'$1', 'cardLast4': r'$2'},
type: TransactionType.income,
),
];
@@ -137,12 +137,8 @@ bool _amountAppearsInBody(int amountMinor, String body) {
return false;
}
/// Сообщение похоже на «не транзакцию» (баланс/реклама) — §8.6 последнее правило.
bool looksLikeNonTransaction(String body) {
final lower = body.toLowerCase();
final hasBalanceWord = lower.contains('баланс') || lower.contains('доступно');
final hasTxKeyword = RegExp(
r'покупка|оплата|списание|зачисление|пополнение|поступление|перевод|возврат')
.hasMatch(lower);
return hasBalanceWord && !hasTxKeyword;
}
/// Сообщение похоже на «не транзакцию» (спам/реклама): в теле нет ни одной
/// цифры. Сообщения с цифрами считаем кандидатами в транзакцию и отдаём в AI;
/// окончательный вердикт «не транзакция» может вынести сама модель
/// (AI вернёт type="ignored").
bool looksLikeNonTransaction(String body) => !RegExp(r'\d').hasMatch(body);
@@ -1,95 +0,0 @@
import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/bank_template.dart';
import '../../domain/entities/parse_draft.dart';
import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart';
import '../bank_templates/bank_templates_catalog.dart';
/// Этап 1 pipeline: разбор тела уведомления встроенными regex-шаблонами.
///
/// Возвращает `null`, если ни один шаблон не совпал — тогда pipeline уходит
/// на AI fallback (Phase 2) или в `parsed_partial`.
class RegexParser {
const RegexParser([this.templates = bankTemplatesCatalog]);
final List<BankTemplate> templates;
ParseDraft? parse(RawMessage msg) {
for (final t in templates) {
if (!t.enabled) continue;
final re = RegExp(t.pattern, caseSensitive: false);
final m = re.firstMatch(msg.body);
if (m == null) continue;
final amountStr = _group(t.extract['amount'], m);
final amount = amountStr == null ? null : parseAmountMinor(amountStr);
if (amount == null || amount <= 0) continue;
return ParseDraft(
rawMessageId: msg.id,
type: t.type,
amount: amount,
currency: 'RUB',
cardLast4: _group(t.extract['cardLast4'], m),
merchantRaw: _group(t.extract['merchant'], m)?.trim(),
counterpartyPhone: _group(t.extract['phone'], m),
kind: _kindFor(t.type),
source: ParseSource.regex,
);
}
return null;
}
/// Извлекает значение по back-reference вида `$1` из совпадения.
String? _group(String? ref, RegExpMatch m) {
if (ref == null) return null;
final n = int.tryParse(ref.replaceAll(r'$', ''));
if (n == null || n > m.groupCount) return null;
final v = m.group(n);
return (v == null || v.isEmpty) ? null : v;
}
TxKind _kindFor(TransactionType type) => switch (type) {
TransactionType.expense => TxKind.purchase,
TransactionType.income => TxKind.transferIn,
TransactionType.transfer => TxKind.transferOut,
};
}
/// Парсит денежную строку РФ-формата в минорные единицы (копейки).
///
/// Поддерживает пробелы/неразрывные пробелы как разделители тысяч и
/// `,`/`.` как десятичный разделитель: `«1 240,50»` → 124050, `«1240»` → 124000.
int? parseAmountMinor(String raw) {
final s = raw.replaceAll(RegExp(r'\s'), '');
if (s.isEmpty) return null;
final lastDot = s.lastIndexOf('.');
final lastComma = s.lastIndexOf(',');
final decPos = lastDot > lastComma ? lastDot : lastComma;
String intPart;
String fracPart;
if (decPos >= 0) {
final after = s.substring(decPos + 1);
// Десятичный разделитель — только если 1–2 цифры в хвосте.
if (after.isNotEmpty && after.length <= 2 && _digitsOnly(after)) {
intPart = s.substring(0, decPos);
fracPart = after.padRight(2, '0');
} else {
intPart = s;
fracPart = '00';
}
} else {
intPart = s;
fracPart = '00';
}
intPart = intPart.replaceAll(RegExp(r'[^\d]'), '');
if (intPart.isEmpty) return null;
final minor = int.tryParse(intPart + fracPart.substring(0, 2));
return minor;
}
bool _digitsOnly(String s) => RegExp(r'^\d+$').hasMatch(s);
@@ -1,30 +0,0 @@
import '../../../../core/database/converters/enum_converters.dart';
/// Встроенный regex-шаблон для разбора текста уведомления.
///
/// Шаблоны хранятся в коде (bank_templates_catalog.dart), не в БД.
/// Пользовательские шаблоны (Advanced settings) хранятся в отдельной таблице,
/// но используют ту же структуру.
///
/// [extract] — именованные capture-группы: ключ = имя поля (amount, cardLast4),
/// значение = back-reference вида r'\$1'.
///
/// Шаблоны RU-only в MVP; локализация шаблонов отложена.
class BankTemplate {
const BankTemplate({
required this.key,
required this.pattern,
required this.extract,
required this.type,
this.enabled = true,
});
final String key;
final String pattern;
final Map<String, String> extract;
final TransactionType type;
final bool enabled;
@override
String toString() => 'BankTemplate($key)';
}
@@ -7,15 +7,21 @@ import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../home/presentation/widgets/money_text.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/ai_providers.dart';
import '../../application/inbox_controller.dart';
import '../../data/parser/draft_codec.dart';
import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart';
import '../widgets/confidence_badge.dart';
enum _LogFilter { all, inbox, applied, ignored, failed }
/// Максимум реальных AI-попыток до статуса `failed` (см. ParsingWorker §7).
const _maxParseAttempts = 5;
enum _LogFilter { all, waiting, inbox, applied, ignored, failed }
/// Журнал парсинга: все `raw_messages` пользователя с их статусом — покрывает
/// «архив raw_messages» и «показать игнорированные/авто-применённые» из Phase 1.
/// Строка разворачивается по тапу и показывает полную отладочную информацию.
class ParsingLogScreen extends ConsumerStatefulWidget {
const ParsingLogScreen({super.key});
@@ -28,6 +34,10 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
bool _matches(RawMessage m) => switch (_filter) {
_LogFilter.all => true,
_LogFilter.waiting => m.status == RawMessageStatus.pendingAi ||
m.status == RawMessageStatus.pending ||
m.status == RawMessageStatus.parsing ||
m.status == RawMessageStatus.parsed,
_LogFilter.inbox => m.status == RawMessageStatus.inbox ||
m.status == RawMessageStatus.parsedPartial,
_LogFilter.applied => m.status == RawMessageStatus.applied,
@@ -52,6 +62,7 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
appBar: AppBar(
backgroundColor: p.paper,
title: Text(l10n.parsingLogTitle),
actions: const [_OnlineIndicator(), SizedBox(width: 12)],
),
body: Column(
children: [
@@ -63,6 +74,7 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
children: [
for (final entry in <(_LogFilter, String)>[
(_LogFilter.all, l10n.parsingLogFilterAll),
(_LogFilter.waiting, l10n.parsingLogFilterWaiting),
(_LogFilter.inbox, l10n.parsingLogFilterInbox),
(_LogFilter.applied, l10n.parsingLogFilterApplied),
(_LogFilter.ignored, l10n.parsingLogFilterIgnored),
@@ -117,15 +129,52 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
}
}
class _LogRow extends ConsumerWidget {
const _LogRow({required this.message});
final RawMessage message;
/// Живой индикатор сети в AppBar — читает [isOnlineProvider] (тот же поток,
/// что использует воркер для ретрая `pending_ai`). Помогает понять, почему
/// сообщение застряло в «Ждёт AI».
class _OnlineIndicator extends ConsumerWidget {
const _OnlineIndicator();
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final online = ref.watch(isOnlineProvider).value;
final (IconData icon, Color color, String label) = switch (online) {
true => (Icons.wifi, p.positive, l10n.parsingLogOnline),
false => (Icons.wifi_off, p.ink2, l10n.parsingLogOffline),
_ => (Icons.wifi, p.ink2, ''),
};
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: color)),
],
);
}
}
class _LogRow extends ConsumerStatefulWidget {
const _LogRow({required this.message});
final RawMessage message;
@override
ConsumerState<_LogRow> createState() => _LogRowState();
}
class _LogRowState extends ConsumerState<_LogRow> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final message = widget.message;
final bundle = decodeDraftBundle(message.draftJson);
final draft = bundle?.draft;
final merchant = draft?.merchantCanonical ?? draft?.merchantRaw;
@@ -138,69 +187,86 @@ class _LogRow extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
_StatusBadge(status: message.status),
const SizedBox(width: 8),
Text(
DateFormat('dd.MM HH:mm').format(message.receivedAt),
style: TextStyle(fontSize: 12, color: p.ink2),
),
const SizedBox(width: 8),
Expanded(
child: Text(
message.packageName,
style: TextStyle(fontSize: 12, color: p.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
],
),
if (draft != null) ...[
const SizedBox(height: 6),
Row(
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = !_expanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Text(
merchant ?? '',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: p.ink),
maxLines: 1,
overflow: TextOverflow.ellipsis,
Row(
children: [
_StatusBadge(status: message.status),
const SizedBox(width: 8),
Text(
DateFormat('dd.MM HH:mm').format(message.receivedAt),
style: TextStyle(fontSize: 12, color: p.ink2),
),
const SizedBox(width: 8),
Expanded(
child: Text(
message.packageName,
style: TextStyle(fontSize: 12, color: p.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: p.ink2,
),
],
),
if (draft != null) ...[
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: Text(
merchant ?? '',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: p.ink),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
MoneyText(
draft.type == TransactionType.expense
? -draft.amount
: draft.amount,
color: draft.type == TransactionType.expense
? p.negative
: p.positive,
fontSize: 14,
fontWeight: FontWeight.w700,
withSign: true,
),
],
),
],
const SizedBox(height: 6),
Text(
message.body,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
maxLines: _expanded ? null : 2,
overflow: _expanded ? null : TextOverflow.ellipsis,
),
MoneyText(
draft.type == TransactionType.expense
? -draft.amount
: draft.amount,
color: draft.type == TransactionType.expense
? p.negative
: p.positive,
fontSize: 14,
fontWeight: FontWeight.w700,
withSign: true,
),
if (message.lastParseError != null) ...[
const SizedBox(height: 6),
Text(
message.lastParseError!,
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
maxLines: _expanded ? null : 2,
overflow: _expanded ? null : TextOverflow.ellipsis,
),
],
],
),
],
const SizedBox(height: 6),
Text(
message.body,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (message.lastParseError != null) ...[
const SizedBox(height: 6),
Text(
message.lastParseError!,
style: TextStyle(fontSize: 12, color: p.negative, height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (_expanded) _DetailPanel(message: message, bundle: bundle),
if (canRetry) ...[
const SizedBox(height: 8),
Align(
@@ -226,6 +292,243 @@ class _LogRow extends ConsumerWidget {
}
}
/// Разворачиваемая панель с полной отладочной информацией по сообщению.
/// Показывает только данные, уже сохранённые в БД (без новых запросов).
class _DetailPanel extends StatelessWidget {
const _DetailPanel({required this.message, required this.bundle});
final RawMessage message;
final DraftBundle? bundle;
static final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final draft = bundle?.draft;
final suggestion = bundle?.suggestion;
String? counterparty;
if (draft != null) {
final parts = [draft.counterpartyName, draft.counterpartyPhone]
.where((e) => e != null && e.isNotEmpty)
.join(' · ');
counterparty = parts.isEmpty ? null : parts;
}
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_section(context, l10n.parsingDetailConfidence, [
_ConfidenceRow(
label: l10n.parsingDetailAmountScore,
score: message.confidenceAmount),
_ConfidenceRow(
label: l10n.parsingDetailAccountScore,
score: message.confidenceAccount),
_ConfidenceRow(
label: l10n.parsingDetailTypeScore,
score: message.confidenceType),
_ConfidenceRow(
label: l10n.parsingDetailMerchantScore,
score: message.confidenceMerchant),
_ConfidenceRow(
label: l10n.parsingDetailCategoryScore,
score: message.confidenceCategory),
]),
_section(context, l10n.parsingDetailPipeline, [
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
l10n.parsingDetailAttempt(
message.parseAttemptCount, _maxParseAttempts),
style: TextStyle(fontSize: 12, color: p.ink, height: 1.3),
),
),
if (draft != null)
_kv(context, l10n.parsingDetailSource, draft.source.name),
]),
_section(context, l10n.parsingDetailMessage, [
_kv(context, l10n.parsingDetailTitle, message.title),
_kv(context, l10n.parsingDetailBody, message.body, selectable: true),
]),
if (draft != null)
_section(context, l10n.parsingDetailDraft, [
_kv(context, l10n.parsingDetailCurrency, draft.currency),
_kv(context, l10n.parsingDetailCard, draft.cardLast4),
_kv(context, l10n.parsingDetailKind, draft.kind?.name),
_kv(
context,
l10n.parsingDetailDate,
draft.dateTime == null
? null
: _dateFmt.format(draft.dateTime!)),
_kv(context, l10n.parsingDetailMerchantRaw, draft.merchantRaw),
_kv(context, l10n.parsingDetailMerchantCanonical,
draft.merchantCanonical),
_kv(context, l10n.parsingDetailCounterparty, counterparty),
_kv(context, l10n.parsingDetailCategorySuggestion,
draft.categorySuggestion),
_kv(context, l10n.parsingDetailAccountId, draft.accountId,
mono: true),
_kv(context, l10n.parsingDetailCategoryId, draft.categoryId,
mono: true),
_kv(context, l10n.parsingDetailTransferTo,
draft.transferToAccountId,
mono: true),
]),
if (suggestion != null)
_section(context, l10n.parsingDetailRule, [
_kv(context, l10n.parsingDetailMerchantCanonical,
suggestion.merchantCanonical),
_kv(context, l10n.parsingDetailRuleCategory,
suggestion.categoryName),
]),
_section(context, l10n.parsingDetailMeta, [
_kv(context, l10n.parsingDetailTransactionId, message.transactionId,
mono: true),
_kv(context, l10n.parsingDetailDedupHash, message.dedupHash,
mono: true),
_kv(context, l10n.parsingDetailReceivedAt,
_dateFmt.format(message.receivedAt)),
_kv(context, l10n.parsingDetailCreatedAt,
_dateFmt.format(message.createdAt)),
]),
],
),
);
}
/// Заголовок секции + её строки. Возвращает пустой блок, если ни одной
/// заполненной строки нет (skip-null поведение `_kv`).
Widget _section(BuildContext context, String title, List<Widget?> rows) {
final visible = rows.whereType<Widget>().toList();
if (visible.isEmpty) return const SizedBox.shrink();
final p = context.palette;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: Text(
title.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: p.ink2,
),
),
),
...visible,
const SizedBox(height: 6),
],
);
}
/// Строка «метка → значение». `null`/пустое значение → строка опускается.
Widget? _kv(BuildContext context, String label, String? value,
{bool mono = false, bool selectable = false}) {
if (value == null || value.isEmpty) return null;
return _DetailRow(
label: label, value: value, mono: mono, selectable: selectable);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({
required this.label,
required this.value,
this.mono = false,
this.selectable = false,
});
final String label;
final String value;
final bool mono;
final bool selectable;
@override
Widget build(BuildContext context) {
final p = context.palette;
final valueStyle = TextStyle(
fontSize: 12,
color: p.ink,
height: 1.3,
fontFamily: mono ? 'monospace' : null,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
),
),
const SizedBox(width: 8),
Expanded(
child: selectable
? SelectableText(value, style: valueStyle)
: Text(value, style: valueStyle),
),
],
),
);
}
}
class _ConfidenceRow extends StatelessWidget {
const _ConfidenceRow({required this.label, required this.score});
final String label;
final int? score;
@override
Widget build(BuildContext context) {
final p = context.palette;
final Color color;
if (score == null) {
color = p.ink2;
} else if (score! >= 85) {
color = p.positive;
} else if (isWeakScore(score)) {
color = p.negative;
} else {
color = p.accent;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 120,
child: Text(label,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
),
const SizedBox(width: 8),
Text(
score?.toString() ?? '',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w700, color: color),
),
],
),
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
@@ -0,0 +1,302 @@
@Tags(['integration'])
library;
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.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/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/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/parser/draft_codec.dart';
import 'package:new_budget/src/features/notification_parsing/data/secure/ai_key_store.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';
/// Интеграционные тесты AI-обработки уведомлений (§7), бьющие в РЕАЛЬНЫЙ
/// OpenRouter API. Проверяют, что после получения сообщения оно
/// автоматически подхватывается [ParsingWorker] и проходит pipeline
/// AI → терминальный статус (inbox / ignored / failed).
///
/// Ключ НЕ хардкодится: берётся из --dart-define=OPENROUTER_API_KEY=...
/// Без ключа сетевые тесты помечаются skip (плоский `flutter test` остаётся
/// зелёным и офлайн). Тест с заведомо плохим ключом сети не требует и идёт
/// всегда.
///
/// flutter test test/features/notification_parsing/integration/ai_processing_integration_test.dart \
/// --tags integration -p vm \
/// --dart-define=OPENROUTER_API_KEY=sk-or-...
const _apiKey = String.fromEnvironment('OPENROUTER_API_KEY');
const _model =
String.fromEnvironment('OPENROUTER_TEST_MODEL', defaultValue: kDefaultAiModel);
final Object _skip =
_apiKey.isEmpty ? 'set --dart-define=OPENROUTER_API_KEY to run' : false;
const _userId = 'user-1';
const _accountId = 'acc-1';
/// Подменяет secure storage, отдавая ключ из dart-define. Так собирается
/// НАСТОЯЩИЙ [aiParserProvider] поверх реального httpClient/OpenRouterClient —
/// без обращения к платформенному FlutterSecureStorage.
class _FakeKeyStore extends AiKeyStore {
const _FakeKeyStore(this.key) : super(const FlutterSecureStorage());
final String key;
@override
Future<String?> getApiKey() async => key;
@override
Future<bool> hasKey() async => key.isNotEmpty;
}
/// Создаёт контейнер с реальной БД и AI-обвязкой на заданном ключе.
ProviderContainer _container(AppDatabase db, String apiKey) {
return ProviderContainer(
overrides: [
appDatabaseProvider.overrideWithValue(db),
aiKeyStoreProvider.overrideWithValue(_FakeKeyStore(apiKey)),
// connectivity_plus не имеет платформенного биндинга под flutter test —
// всегда «онлайн», иначе воркер уведёт сообщение в pending_ai.
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
],
);
}
Future<void> _seed(AppDatabase db, {List<String> categories = const []}) async {
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Тест'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: _accountId,
userId: _userId,
name: 'Основной',
),
);
var i = 0;
for (final name in categories) {
await db.categoriesDao.insertCategory(
CategoriesTableCompanion.insert(
id: 'cat-${i++}',
userId: _userId,
name: name,
),
);
}
}
/// Активирует воркер: помимо чтения самого провайдера держим прямого слушателя
/// на его входном потоке [pendingMessagesProvider]. Без внешнего слушателя
/// autoDispose-стрим не подписывается на Drift и `ref.listen` внутри воркера не
/// получает событий (в приложении эту роль играет `HomeScreen`).
void _activateWorker(ProviderContainer c) {
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
c.read(parsingWorkerProvider(_userId));
}
/// Ждёт, пока сообщение [id] не выйдет из «промежуточных» статусов
/// (pending / parsing / pending_ai) в терминальный.
Future<RawMessage> _waitTerminal(
RawMessagesRepository repo,
String id, {
Duration timeout = const Duration(seconds: 60),
}) 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() {
const netTimeout = Timeout(Duration(seconds: 90));
// ── AI-обработка через реальный OpenRouter (требует ключ) ────────────────
group('AI auto-processing (real OpenRouter)', () {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db, categories: const ['Продукты', 'Кафе и рестораны']);
container = _container(db, _apiKey);
repo = container.read(rawMessagesRepositoryProvider);
// Согласие на AI (по умолчанию false → AI не вызывается). Модель — из
// dart-define/дефолта приложения.
final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future);
await settings.setAiConsent(true);
await settings.setAiModel(_model);
});
tearDown(() async {
container.dispose();
await db.close();
});
test(
'английское уведомление о покупке → AI → Inbox с draft',
() async {
// Активируем воркер — он сам слушает pending и тянет pipeline.
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Payment of 1500 RUB at LENTA supermarket, card ending 7777',
receivedAt: DateTime(2026, 5, 31, 12, 0),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.inbox,
reason: 'lastParseError=${msg.lastParseError}');
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull, reason: 'draftJson must hold the AI draft');
final draft = bundle!.draft;
expect(draft.source, ParseSource.ai);
expect(draft.type, TransactionType.expense);
// Сумма в минорных единицах (~1500.00 ₽). Допускаем небольшой разброс.
expect(draft.amount, greaterThanOrEqualTo(140000));
expect(draft.amount, lessThanOrEqualTo(160000));
expect(draft.merchantRaw, isNotNull);
expect(draft.merchantRaw!.toUpperCase(), contains('LENTA'));
// Токены учтены в дневном счётчике.
final settings =
await container.read(parsingSettingsControllerProvider.future);
expect(settings.tokensUsedToday, greaterThan(0));
},
timeout: netTimeout,
skip: _skip,
);
test(
'не-транзакция (баланс/реклама) → AI помечает ignored',
() async {
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Your card balance is 5000 RUB. Thank you for banking with us.',
receivedAt: DateTime(2026, 5, 31, 12, 5),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.ignored,
reason: 'lastParseError=${msg.lastParseError}');
},
timeout: netTimeout,
skip: _skip,
);
test(
'покупка в продуктовом → AI предлагает категорию из списка пользователя',
() async {
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Purchase 980 RUB at PYATEROCHKA grocery store, card 1234',
receivedAt: DateTime(2026, 5, 31, 12, 10),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.inbox,
reason: 'lastParseError=${msg.lastParseError}');
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull);
final draft = bundle!.draft;
// Модель должна предложить категорию (имя — из списка пользователя).
expect(draft.categorySuggestion, isNotNull);
// Если предложение правила подставило категорию — это одна из seed-категорий.
final suggestion = bundle.suggestion;
if (suggestion?.categoryName != null) {
expect(
const ['Продукты', 'Кафе и рестораны'],
contains(suggestion!.categoryName),
);
expect(suggestion.categoryId, isNotNull);
}
},
timeout: netTimeout,
skip: _skip,
);
});
// ── Деградация при неверном ключе (сеть не нужна, идёт всегда) ────────────
group('AI auth failure handling', () {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = _container(db, 'sk-or-invalid-key-for-test');
repo = container.read(rawMessagesRepositoryProvider);
final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future);
await settings.setAiConsent(true);
await settings.setAiModel(_model);
});
tearDown(() async {
container.dispose();
await db.close();
});
test(
'неверный ключ (401/403) → сообщение failed, согласие на AI снимается',
() async {
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Payment of 1500 RUB at LENTA supermarket, card ending 7777',
receivedAt: DateTime(2026, 5, 31, 12, 0),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.failed);
final settings =
await container.read(parsingSettingsControllerProvider.future);
expect(settings.aiConsentGiven, isFalse,
reason: 'auth failure must disable AI consent');
},
timeout: netTimeout,
);
});
}
@@ -116,8 +116,15 @@ void main() {
});
group('looksLikeNonTransaction', () {
test('balance-only message → true', () {
expect(looksLikeNonTransaction('Доступно по карте 5000 ₽'), isTrue);
test('no digits (ad/spam) → true', () {
expect(
looksLikeNonTransaction('Скидки в нашем магазине только сегодня'),
isTrue,
);
});
test('balance with amount → false (есть цифры → кандидат, решит AI)', () {
expect(looksLikeNonTransaction('Доступно по карте 5000 ₽'), isFalse);
});
test('purchase message → false', () {
@@ -1,100 +0,0 @@
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/regex_parser.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';
RawMessage _msg(String body, {String pkg = 'ru.sberbankmobile'}) {
final now = DateTime(2026, 5, 29, 14, 5);
return RawMessage(
id: 'm1',
userId: 'u1',
packageName: pkg,
body: body,
receivedAt: now,
dedupHash: 'h',
status: RawMessageStatus.pending,
createdAt: now,
);
}
void main() {
const parser = RegexParser();
group('parseAmountMinor', () {
test('plain integer → minor units', () {
expect(parseAmountMinor('1240'), 124000);
});
test('RU thousands separator + decimal comma', () {
expect(parseAmountMinor('1 240,50'), 124050);
});
test('dot decimal', () {
expect(parseAmountMinor('99.90'), 9990);
});
test('trailing dot in 3-digit group is NOT a decimal', () {
// «1.240» — точка как разделитель тысяч (3 цифры после) → 124000.
expect(parseAmountMinor('1.240'), 124000);
});
test('empty / garbage → null', () {
expect(parseAmountMinor(''), isNull);
expect(parseAmountMinor(' '), isNull);
});
});
group('RegexParser.parse', () {
test('purchase amount→card→merchant (Сбер-подобный)', () {
final d = parser.parse(
_msg('Покупка 1240р Карта *3456 PYATEROCHKA 14:05 Баланс: 5000р'),
);
expect(d, isNotNull);
expect(d!.type, TransactionType.expense);
expect(d.amount, 124000);
expect(d.cardLast4, '3456');
expect(d.merchantRaw, 'PYATEROCHKA');
expect(d.source, ParseSource.regex);
expect(d.kind, TxKind.purchase);
});
test('purchase card→amount→merchant (Тинькофф-подобный)', () {
final d = parser.parse(
_msg('Покупка. Карта *3456. 1 240 ₽. Пятёрочка. Доступно 5 000 ₽'),
);
expect(d, isNotNull);
expect(d!.type, TransactionType.expense);
expect(d.amount, 124000);
expect(d.cardLast4, '3456');
expect(d.merchantRaw, 'Пятёрочка');
});
test('income (зачисление)', () {
final d = parser.parse(_msg('Зачисление 5000 ₽ Карта *3456 Зарплата'));
expect(d, isNotNull);
expect(d!.type, TransactionType.income);
expect(d.amount, 500000);
expect(d.cardLast4, '3456');
});
test('SBP transfer by phone', () {
final d = parser.parse(_msg('Перевод 1000 ₽ на +79991234567 успешно'));
expect(d, isNotNull);
expect(d!.amount, 100000);
expect(d.counterpartyPhone, '+79991234567');
});
test('refund → income', () {
final d = parser.parse(_msg('Возврат 1240 ₽ Карта *3456'));
expect(d, isNotNull);
expect(d!.type, TransactionType.income);
expect(d.amount, 124000);
});
test('non-matching body → null', () {
expect(parser.parse(_msg('Ваш баланс по карте *3456 составляет 5000 ₽')),
isNull);
});
});
}