# План развития тестов 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())`, работает только в 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, повторный сид), помечены комментарием `// Пиннит текущее поведение: ...` — чтобы при осознанном изменении поведения их меняли, а не «чинили».