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>
334 lines
23 KiB
Markdown
334 lines
23 KiB
Markdown
# План развития тестов NewBudget
|
||
|
||
Документ — результат ревью существующих тестов (23 файла, ~4000 строк, июнь 2026).
|
||
Существующие тесты в хорошем состоянии: их **не трогаем** (кроме пары мелочей в Этапе 6).
|
||
План закрывает пробелы покрытия в порядке убывания ценности.
|
||
|
||
## Сводка этапов
|
||
|
||
| Этап | Что | Зачем | Объём (~тестов) |
|
||
|---|---|---|---|
|
||
| 1 | `month_summary` — агрегаты главного экрана | Не покрыта ключевая бизнес-логика; запланирован фикс переводов | 12–15 |
|
||
| 2 | `parseAmountToMinor` + `Money` | Денежный ввод не тестируется нигде | 15–20 |
|
||
| 3 | Миграции → drift `SchemaVerifier` | Текущий подход «отката» схемы растёт квадратично | 3 (переписать) |
|
||
| 4 | Негативные сценарии `ParsingPipeline` | Покрыт только happy-path; инфраструктура уже готова | 6–8 |
|
||
| 5 | Accounts / Categories / UserSeeder | Ноль тестов на целые фичи | 12–15 |
|
||
| 6 | Мелкие улучшения существующих тестов | Хрупкость и дубли | — |
|
||
|
||
Этапы независимы — можно делать выборочно. Внутри этапа порядок кейсов = приоритет.
|
||
|
||
## Конвенции (обязательны для всех новых тестов)
|
||
|
||
Из `CLAUDE.md` и сложившейся практики:
|
||
|
||
- **Без mocktail/mockito.** Фейки: `FakeXxxRepository implements XxxRepository` с
|
||
`noSuchMethod => throw UnimplementedError(...)` для неиспользуемых методов;
|
||
`FakeXxxController extends XxxController` с переопределённым `build()`.
|
||
- Unit: `ProviderContainer(overrides: [...])` + `addTearDown(container.dispose)`.
|
||
Widget: `ProviderScope(overrides: [...], child: MaterialApp(...))` +
|
||
`GoogleFonts.config.allowRuntimeFetching = false` в `setUpAll`.
|
||
- Реальная БД: `AppDatabase.forTesting(NativeDatabase.memory())`, сидим FK-цепочку
|
||
user → account → category перед транзакциями.
|
||
- `import 'package:drift/drift.dart' hide isNull, isNotNull;` при конфликте с matcher.
|
||
- Тексты в UI-тестах — через `AppLocalizations.delegate.load(...)`, не литералами
|
||
(см. `inbox_card_test.dart:139` как образец).
|
||
- Имена тестов — по-русски, описывают поведение, а не реализацию.
|
||
|
||
---
|
||
|
||
## Этап 1. `month_summary` — агрегаты главного экрана
|
||
|
||
**Файл кода:** `lib/src/features/home/presentation/month_summary.dart`
|
||
**Новый тест:** `test/features/home/month_summary_test.dart`
|
||
|
||
Это самая важная дыра: чистая агрегатная логика (доход/расход/переводы, «все счета»
|
||
vs конкретный счёт), которую пользователь видит каждый день. В бэклоге висит
|
||
«Transfer transactions: fix balance aggregation» — тесты должны **запиннить текущее
|
||
поведение до фикса**, чтобы фикс делался осознанным изменением ассертов.
|
||
|
||
### Инфраструктура
|
||
|
||
Провайдер читает `transactionsStreamProvider(userId, from:, to:)` через
|
||
`.watch(...).value ?? []`. Переопределяем стрим:
|
||
|
||
```dart
|
||
ProviderContainer(overrides: [
|
||
transactionsStreamProvider(userId, from: monthStart, to: monthEnd)
|
||
.overrideWith((ref) => Stream.value(txs)),
|
||
]);
|
||
```
|
||
|
||
**Гочи:**
|
||
- `from`/`to` в override должны бит-в-бит совпадать с тем, что вычисляет провайдер:
|
||
`monthStart = DateTime(y, m)`, `monthEnd = DateTime(y, m + 1).subtract(Duration(milliseconds: 1))`.
|
||
Месяц фиксируем через `selectedMonthProvider` (см. ниже), иначе провайдер возьмёт
|
||
`DateTime.now()` и override не сматчится.
|
||
- `Stream.value` эмитит асинхронно — первый синхронный `read` увидит `AsyncLoading`
|
||
и вернёт пустой список. Перед ассертами:
|
||
`await container.read(transactionsStreamProvider(...).future);`
|
||
- Месяц: `container.read(selectedMonthProvider.notifier)` не имеет сеттера на произвольную
|
||
дату (только `previous`/`next`). Два варианта: (а) строить тестовые транзакции в
|
||
текущем месяце `DateTime.now()`, как делает `tx_row_habit_test`; (б) добавить в
|
||
`SelectedMonth` метод `select(DateTime)` — он пригодится и для UI. Предпочтителен (б),
|
||
но это изменение кода — согласовать. По умолчанию — (а).
|
||
- Счёт: `container.read(selectedAccountProvider.notifier).select('a-1')`;
|
||
«Все счета» = дефолт (`kAllAccountsId == ''`).
|
||
|
||
### Кейсы `monthSummaryProvider`
|
||
|
||
Фикстура: счета `a1`, `a2`; категории `c1`, `c2`. Транзакции (минорные единицы):
|
||
|
||
| id | тип | счёт | категория | сумма | примечание |
|
||
|---|---|---|---|---|---|
|
||
| t1 | expense | a1 | c1 | 1000 | |
|
||
| t2 | expense | a1 | c2 | 2000 | |
|
||
| t3 | expense | a2 | c1 | 400 | |
|
||
| t4 | income | a1 | — | 5000 | |
|
||
| t5 | transfer a1→a2 | a1 | — | 700 | `transferToAccountId: a2` |
|
||
| t6 | expense | a1 | null | 300 | без категории |
|
||
|
||
1. **«Все счета»: income/expense/balance.** income=5000, expense=1000+2000+400+300=3700,
|
||
balance=1300. Переводы (t5) не входят ни в доход, ни в расход.
|
||
2. **«Все счета»: spendByCategory.** `{c1: 1400, c2: 2000}`; t6 (без категории) в карту
|
||
не попадает, но в `expensesMinor` входит. Отдельный ассерт на это расхождение —
|
||
оно неочевидно и влияет на донат-чарт.
|
||
3. **«Все счета»: transactionsCount = 6** (переводы считаются в количестве).
|
||
4. **Счёт a1:** income=5000, expense=1000+2000+300+700(перевод-исход)=4000, balance=1000.
|
||
5. **Счёт a2:** income=700 (входящий перевод t5), expense=400, balance=300.
|
||
Это пиннит текущее поведение «перевод НА счёт = доход» — при будущем фиксе
|
||
агрегации ассерт меняется осознанно.
|
||
6. **Счёт a2: transactionsCount = 2** (t3 + входящий t5).
|
||
7. **Пустой месяц** (нет транзакций) → все нули, пустая карта.
|
||
8. **spendByCategory не зависит от категория-фильтра** — фильтр категорий влияет только
|
||
на `filteredTransactions`, не на summary (задокументированное поведение).
|
||
|
||
### Кейсы `filteredTransactionsProvider`
|
||
|
||
9. Без фильтров → все 6.
|
||
10. Счёт a2 → t3 + входящий перевод t5 (для конкретного счёта переводы НА него включаются).
|
||
11. Категория-фильтр `{c1}` → t1, t3 (t5/t4/t6 отпадают: их `categoryId` не в множестве).
|
||
12. Комбинация счёт a1 + категория `{c2}` → только t2.
|
||
|
||
### Кейсы `categoriesBySpend` (чистая функция, без контейнера)
|
||
|
||
13. Сортировка по убыванию суммы.
|
||
14. Категории с нулевой/отсутствующей тратой не попадают в результат.
|
||
15. Категория есть в spend, но нет в списке categories (удалена/архив) → не попадает,
|
||
без исключения.
|
||
|
||
---
|
||
|
||
## Этап 2. Деньги: `parseAmountToMinor` + `Money`
|
||
|
||
### 2a. `parseAmountToMinor`
|
||
|
||
**Файл кода:** `lib/src/features/transactions/presentation/widgets/amount_input.dart:12`
|
||
**Новый тест:** `test/features/transactions/presentation/amount_input_test.dart`
|
||
|
||
Чистая функция, тестируется без виджетов. Кейсы:
|
||
|
||
| Ввод | Ожидание | Что проверяет |
|
||
|---|---|---|
|
||
| `'1234'` | 123400 | целое → ×100 |
|
||
| `'1234.56'` | 123456 | точка-разделитель |
|
||
| `'1234,56'` | 123456 | запятая-разделитель |
|
||
| `'1 234,56'` | 123456 | пробелы внутри числа |
|
||
| `'.5'` / `',5'` | 50 | пустая целая часть |
|
||
| `'12.5'` | 1250 | один знак дробной — `padRight` |
|
||
| `'12.567'` | 1256 | >2 знаков — усечение (НЕ округление: 12.567 → 1256). Пиннит текущее поведение |
|
||
| `''` / `' '` | 0 | пусто |
|
||
| `'1.2.3'` | 0 | два разделителя |
|
||
| `'abc'`, `'12a'` | 0 | мусор |
|
||
| `'0'` | 0 | ноль (форма должна отклонить как «Enter an amount») |
|
||
|
||
Плюс 1–2 widget-теста на сам `AmountInput`: ввод `'1 234,56'` через
|
||
`tester.enterText` → `onChanged` получил 123456; `inputFormatters` не пропускают буквы.
|
||
(Это закроет разрыв: текущие тесты формы задают сумму через `notifier.setAmount`,
|
||
минуя парсинг.)
|
||
|
||
### 2b. `Money`
|
||
|
||
**Файл кода:** `lib/src/core/money/money.dart`
|
||
**Новый тест:** `test/core/money/money_test.dart`
|
||
|
||
1. `fromAmount(12.34, 'RUB')` → 1234; `fromAmount(0.1 + 0.2, ...)` → 30 (round спасает от FP).
|
||
2. `fromAmount(100, 'JPY')` → 100 (нулевые decimals); `amount` для JPY не делит на 100.
|
||
3. Round-trip: `fromAmount(x).amount == x` для типичных значений.
|
||
4. Операторы `+`/`-`/`*` (включая `* 0.5` с округлением `.round()` — банковское не используется, пиннить как есть).
|
||
5. `==`/`hashCode`: равенство по minorUnits+currency; разные валюты не равны.
|
||
6. `toString`: `'RUB 12.34'`, `'JPY 100'`.
|
||
7. (Опционально) assert при `+` разных валют — проверять через `throwsA(isA<AssertionError>())`,
|
||
работает только в debug; пометить комментарием.
|
||
|
||
---
|
||
|
||
## Этап 3. Миграции БД → drift `SchemaVerifier`
|
||
|
||
**Сейчас:** `migration_v6/v7/v8_test.dart` поднимают актуальную схему и «откатывают» её
|
||
вручную (`ALTER TABLE ... DROP COLUMN`, `PRAGMA user_version`). Проблемы:
|
||
|
||
- Квадратичный рост: v6-тест уже вынужден удалять артефакты v7 **и** v8; каждая новая
|
||
версия схемы требует править все старые тесты.
|
||
- Тестируется «схема, похожая на старую», а не реальная старая: отличия в дефолтах,
|
||
индексах, constraint'ах не ловятся.
|
||
|
||
**Целевое состояние** — штатный механизм drift:
|
||
|
||
1. Снять снапшоты схем: `dart run drift_dev schema dump lib/src/core/database/app_database.dart drift_schemas/`
|
||
(создаст `drift_schemas/drift_schema_vN.json`; коммитятся в репозиторий).
|
||
Снапшоты старых версий генерируются один раз из git-истории: checkout коммита с
|
||
`schemaVersion = N` → dump → вернуть HEAD. Версии для checkout искать по
|
||
`git log -S 'schemaVersion' -- lib/src/core/database/app_database.dart`.
|
||
2. Сгенерировать тестовую обвязку:
|
||
`dart run drift_dev schema generate drift_schemas/ test/core/database/generated/`.
|
||
3. Переписать три теста на `SchemaVerifier` (`package:drift_dev/api/migrations.dart`):
|
||
|
||
```dart
|
||
final verifier = SchemaVerifier(GeneratedHelper());
|
||
final connection = await verifier.startAt(5);
|
||
final db = AppDatabase(connection);
|
||
await verifier.migrateAndValidate(db, 8);
|
||
```
|
||
|
||
4. Сохранить смысловые round-trip-проверки из текущих тестов (вставка строки с
|
||
`obligation`/`txType` после миграции) — `migrateAndValidate` проверяет структуру,
|
||
но не конвертеры enum'ов.
|
||
5. Добавить в `CLAUDE.md` правило: при бампе `schemaVersion` — новый dump + тест
|
||
`startAt(N-1) → migrateAndValidate(N)`.
|
||
|
||
**Definition of done:** старые три файла удалены, новые тесты зелёные, процедура
|
||
снапшота задокументирована. Если шаг 1 (восстановление старых схем из git) окажется
|
||
дорогим — допустимый компромисс: зафиксировать текущую v8 как первый снапшот и
|
||
переводить на SchemaVerifier только будущие миграции (v8 → v9+), оставив старые
|
||
тесты как есть до их естественного устаревания.
|
||
|
||
---
|
||
|
||
## Этап 4. Негативные сценарии `ParsingPipeline` / воркера
|
||
|
||
**Новый тест:** `test/features/notification_parsing/application/parsing_pipeline_negative_test.dart`
|
||
**Переиспользовать:** `_fakeAiParser` (MockClient), `_seed`, `_activateWorker`,
|
||
`_waitTerminal` из `parsing_worker_allowlist_test.dart` — **вынести их в
|
||
`test/features/notification_parsing/support.dart`**, чтобы не копировать.
|
||
|
||
Кейсы (все офлайн, через MockClient):
|
||
|
||
1. **AI вернул битый JSON** (MockClient отдаёт `'not a json'` в `content`) →
|
||
терминальный статус `failed`, `lastParseError` непустой, транзакций нет.
|
||
(Проверить заодно `ai_tolerant_json.dart`: что именно он прощает — markdown-обёртку
|
||
```` ```json ```` — а что нет.)
|
||
2. **AI вернул HTTP 500** → `failed` (или retry-поведение, если оно есть — пиннить фактическое).
|
||
3. **`autoApplyEnabled = false`** при полностью проходящем чек-листе (правило + сумма
|
||
в теле + дефолтный счёт) → `inbox`, транзакций нет. Сейчас toggle проверен только
|
||
на уровне `decide()`, но не сквозь pipeline.
|
||
4. **Правило `ignore`** на мерчанта → статус `ignored`, AI **не вызывался** (`onCall`-флаг).
|
||
5. **Дедупликация:** два `insertIncoming` с одинаковыми `packageName`+`body` →
|
||
второе сообщение не порождает второй обработки/транзакции (проверить фактический
|
||
контракт `insertIncoming`: возвращает существующее? вставляет со статусом `duplicate`?
|
||
— пиннить реальное поведение).
|
||
6. **Дневной лимит токенов исчерпан** (`setAiDailyTokenLimit(10)` + `addTokenUsage(20)`)
|
||
→ AI не вызывается, сообщение уходит в ожидаемый статус (по коду — regex-only путь;
|
||
уточнить по `parsing_pipeline.dart` и зафиксировать).
|
||
|
||
### 4b. `ParsingSettingsController` (unit, in-memory БД)
|
||
|
||
**Новый тест:** `test/features/notification_parsing/application/parsing_settings_controller_test.dart`
|
||
|
||
1. Дефолты: `enabled=true`, `autoApply=true`, `aiConsent=false`, модель = `kDefaultAiModel`.
|
||
2. `addTokenUsage` аккумулирует; `tokensUsedToday` в state обновляется.
|
||
3. `isDailyLimitReached`: false без лимита; true при `used >= limit`.
|
||
4. Смена дня: ключ счётчика содержит дату (`ai_token_usage_YYYY-MM-DD`).
|
||
`DateTime.now()` не инжектится — честно протестировать «вчерашний счётчик не
|
||
читается» можно, записав преференс с вчерашним ключом напрямую через
|
||
`settingsDao.setPreference('ai_token_usage_<вчера>', '999')` и проверив, что
|
||
`build()` вернул 0. (Опционально: рефакторинг `_tokenUsageKey` на инжектируемые
|
||
часы — отдельным решением.)
|
||
5. Персистентность: значения переживают пересоздание контейнера над той же БД.
|
||
|
||
---
|
||
|
||
## Этап 5. Accounts / Categories / UserSeeder
|
||
|
||
### 5a. `AccountRepositoryImpl` (in-memory БД)
|
||
|
||
**Новый тест:** `test/features/accounts/repository/account_repository_test.dart`
|
||
|
||
1. CRUD round-trip: create → findById с полями (currency, iconCode, colorValue, initialBalance).
|
||
2. **`setDefault`: инвариант единственного дефолта.** Два счёта, `setDefault(a1)`,
|
||
затем `setDefault(a2)` → `isDefault` только у a2. Это опора account resolver'а
|
||
(`globalDefault`, score 40) и gate-проверки `accountTrusted` — самый ценный кейс этапа.
|
||
3. `setDefault(null, userId)` снимает дефолт со всех.
|
||
4. `archive`: архивный счёт исчезает из основного watch-потока (или помечается — по
|
||
фактическому контракту DAO), но находится по id.
|
||
5. Изоляция пользователей: счета другого `userId` не видны.
|
||
|
||
### 5b. `CategoryRepositoryImpl`
|
||
|
||
**Новый тест:** `test/features/categories/repository/category_repository_test.dart`
|
||
|
||
1. CRUD round-trip, включая `parentId` (подкатегория) и его обнуление.
|
||
2. Фильтрация по `type` (expense/income) — то, на что завязана форма транзакции.
|
||
3. `archive` + изоляция пользователей (аналогично счетам).
|
||
|
||
### 5c. `UserSeeder` (реальный, in-memory БД)
|
||
|
||
**Новый тест:** `test/features/user/user_seeder_test.dart`
|
||
|
||
Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом
|
||
от него зависит первый запуск приложения.
|
||
|
||
1. `seedForNewUser(userId)` → созданы дефолтные **категории** (количество > 0; точные
|
||
наборы не пиннить), все с правильным `userId`. Счета НЕ создаются и `isDefault` не
|
||
выставляется — первый счёт создаёт пользователь на втором шаге онбординга.
|
||
2. `seedDemoTransactionsForUser(userId)` (ручной путь из профиля) — создаёт недостающие
|
||
счета/категории и демо-транзакции, ссылающиеся на них (FK-цепочка цела).
|
||
Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md)
|
||
достаточно было удалить один блок ассертов.
|
||
4. Повторный вызов для того же пользователя: пиннить фактическое поведение
|
||
(дубли? идемпотентность?) — это поведение при «втором онбординге».
|
||
|
||
### 5d. Контроллеры accounts/categories — только если в них есть логика
|
||
|
||
Если `AccountsController`/`CategoriesController` лишь проксируют репозиторий —
|
||
отдельные тесты не нужны (паттерн уже покрыт `users_controller_test`). Тестировать
|
||
только если есть валидация/оркестрация (проверить по коду перед написанием).
|
||
|
||
---
|
||
|
||
## Этап 6. Мелкие улучшения существующих тестов
|
||
|
||
1. **`onboarding_screen_test.dart`:** заменить литералы `'Welcome'`, `'Continue'`,
|
||
`'Your name'` на строки из `AppLocalizations.delegate.load(const Locale('en'))` —
|
||
по образцу `inbox_card_test.dart:139`. Иначе правка ARB валит тесты с невнятной
|
||
ошибкой.
|
||
2. **Общие фейки:** `FakeTransactionRepository` существует в трёх вариантах
|
||
(transactions_controller, inbox_controller, форма). Вынести один полный в
|
||
`test/support/fakes.dart`, остальные удалить. Туда же — `_UnusedXxxRepo`-заглушки.
|
||
3. **`widget_test.dart`:** добавить ассерт, что без активного пользователя показан
|
||
`OnboardingScreen` (проверка redirect-логики роутера почти бесплатно).
|
||
4. **`usersStreamProvider`-тест** (`users_controller_test.dart:332`): сейчас фейковый
|
||
`watchAll()` возвращает одноразовый `Stream.value`, и реактивность не проверяется.
|
||
Заменить в фейке на `StreamController.broadcast` с ре-эмитом после `create` —
|
||
тогда тест начнёт проверять то, что декларирует.
|
||
5. **`test/features/notification_parsing/support.dart`** — см. Этап 4 (общая обвязка
|
||
воркер-тестов).
|
||
|
||
---
|
||
|
||
## Команды
|
||
|
||
```bash
|
||
flutter test # весь оффлайн-набор (должен быть зелёным всегда)
|
||
flutter test test/features/home/month_summary_test.dart # один файл
|
||
flutter test --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-... # сетевые
|
||
dart run build_runner build --delete-conflicting-outputs # если меняли @riverpod-код
|
||
```
|
||
|
||
## Definition of done (на каждый этап)
|
||
|
||
- `flutter analyze` чистый, `flutter test` зелёный без сети и без API-ключей.
|
||
- Новые тесты следуют конвенциям из раздела выше (фейки без mockito, l10n без литералов).
|
||
- Тесты, пиннящие «спорное» текущее поведение (переводы в month_summary, усечение
|
||
в parseAmountToMinor, повторный сид), помечены комментарием `// Пиннит текущее
|
||
поведение: ...` — чтобы при осознанном изменении поведения их меняли, а не «чинили».
|