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

23 KiB
Raw Blame History

План развития тестов NewBudget

Документ — результат ревью существующих тестов (23 файла, ~4000 строк, июнь 2026). Существующие тесты в хорошем состоянии: их не трогаем (кроме пары мелочей в Этапе 6). План закрывает пробелы покрытия в порядке убывания ценности.

Сводка этапов

Этап Что Зачем Объём (~тестов)
1 month_summary — агрегаты главного экрана Не покрыта ключевая бизнес-логика; запланирован фикс переводов 1215
2 parseAmountToMinor + Money Денежный ввод не тестируется нигде 1520
3 Миграции → drift SchemaVerifier Текущий подход «отката» схемы растёт квадратично 3 (переписать)
4 Негативные сценарии ParsingPipeline Покрыт только happy-path; инфраструктура уже готова 68
5 Accounts / Categories / UserSeeder Ноль тестов на целые фичи 1215
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 ?? []. Переопределяем стрим:

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

  1. Без фильтров → все 6.
  2. Счёт a2 → t3 + входящий перевод t5 (для конкретного счёта переводы НА него включаются).
  3. Категория-фильтр {c1} → t1, t3 (t5/t4/t6 отпадают: их categoryId не в множестве).
  4. Комбинация счёт a1 + категория {c2} → только t2.

Кейсы categoriesBySpend (чистая функция, без контейнера)

  1. Сортировка по убыванию суммы.
  2. Категории с нулевой/отсутствующей тратой не попадают в результат.
  3. Категория есть в 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.enterTextonChanged получил 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):
final verifier = SchemaVerifier(GeneratedHelper());
final connection = await verifier.startAt(5);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 8);
  1. Сохранить смысловые round-trip-проверки из текущих тестов (вставка строки с obligation/txType после миграции) — migrateAndValidate проверяет структуру, но не конвертеры enum'ов.
  2. Добавить в 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 500failed (или 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) достаточно было удалить один блок ассертов.
  3. Повторный вызов для того же пользователя: пиннить фактическое поведение (дубли? идемпотентность?) — это поведение при «втором онбординге».

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 (общая обвязка воркер-тестов).

Команды

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