Files
OnBudget/docs/TEST_PLAN.md
T
SandersandClaude Opus 4.8 da65bf6f8e Add per-app rules, transfer pairing, self-merchant flag, two-step onboarding
Notification parsing:
- Per-app parse rules (parse_rules.packageName; getEnabledForApp, NULL = legacy global)
- Transfer pairing: transfer_pair_matcher + transfer_pairing_blocklist table/dao/repo
- source_apps.selfMerchant flag (Ozon inbox rework: default account picker, suppress
  AI category prefill + rule suggestion)
- raw_messages.diagnostics dump captured under diagnostic-mode toggle
- Inbox card / settings / log UI reworks

Onboarding:
- Two-step flow (name -> first account); UserSeeder seeds categories only, no accounts

Schema bumped to v11; drop obsolete migration + mixed-merchant tests, add new coverage.
Add ios/ platform folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:49:04 +03:00

334 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План развития тестов 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, повторный сид), помечены комментарием `// Пиннит текущее
поведение: ...` — чтобы при осознанном изменении поведения их меняли, а не «чинили».