- Android NotificationListenerService plugin + queue store, MethodChannel bridge, and Flutter ingest worker / access controller - source_apps allowlist (DAO, repo, settings + source_apps screen) - schema v8 migration + decision_gate / dedup / draft_codec updates - l10n strings and tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 KiB
План развития тестов 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 ?? []. Переопределяем стрим:
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 | без категории |
- «Все счета»: income/expense/balance. income=5000, expense=1000+2000+400+300=3700, balance=1300. Переводы (t5) не входят ни в доход, ни в расход.
- «Все счета»: spendByCategory.
{c1: 1400, c2: 2000}; t6 (без категории) в карту не попадает, но вexpensesMinorвходит. Отдельный ассерт на это расхождение — оно неочевидно и влияет на донат-чарт. - «Все счета»: transactionsCount = 6 (переводы считаются в количестве).
- Счёт a1: income=5000, expense=1000+2000+300+700(перевод-исход)=4000, balance=1000.
- Счёт a2: income=700 (входящий перевод t5), expense=400, balance=300. Это пиннит текущее поведение «перевод НА счёт = доход» — при будущем фиксе агрегации ассерт меняется осознанно.
- Счёт a2: transactionsCount = 2 (t3 + входящий t5).
- Пустой месяц (нет транзакций) → все нули, пустая карта.
- spendByCategory не зависит от категория-фильтра — фильтр категорий влияет только
на
filteredTransactions, не на summary (задокументированное поведение).
Кейсы filteredTransactionsProvider
- Без фильтров → все 6.
- Счёт a2 → t3 + входящий перевод t5 (для конкретного счёта переводы НА него включаются).
- Категория-фильтр
{c1}→ t1, t3 (t5/t4/t6 отпадают: ихcategoryIdне в множестве). - Комбинация счёт a1 + категория
{c2}→ только t2.
Кейсы categoriesBySpend (чистая функция, без контейнера)
- Сортировка по убыванию суммы.
- Категории с нулевой/отсутствующей тратой не попадают в результат.
- Категория есть в 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
fromAmount(12.34, 'RUB')→ 1234;fromAmount(0.1 + 0.2, ...)→ 30 (round спасает от FP).fromAmount(100, 'JPY')→ 100 (нулевые decimals);amountдля JPY не делит на 100.- Round-trip:
fromAmount(x).amount == xдля типичных значений. - Операторы
+/-/*(включая* 0.5с округлением.round()— банковское не используется, пиннить как есть). ==/hashCode: равенство по minorUnits+currency; разные валюты не равны.toString:'RUB 12.34','JPY 100'.- (Опционально) 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:
- Снять снапшоты схем:
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. - Сгенерировать тестовую обвязку:
dart run drift_dev schema generate drift_schemas/ test/core/database/generated/. - Переписать три теста на
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);
- Сохранить смысловые round-trip-проверки из текущих тестов (вставка строки с
obligation/txTypeпосле миграции) —migrateAndValidateпроверяет структуру, но не конвертеры enum'ов. - Добавить в
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):
- AI вернул битый JSON (MockClient отдаёт
'not a json'вcontent) → терминальный статусfailed,lastParseErrorнепустой, транзакций нет. (Проверить заодноai_tolerant_json.dart: что именно он прощает — markdown-обёртку```json— а что нет.) - AI вернул HTTP 500 →
failed(или retry-поведение, если оно есть — пиннить фактическое). autoApplyEnabled = falseпри полностью проходящем чек-листе (правило + сумма в теле + дефолтный счёт) →inbox, транзакций нет. Сейчас toggle проверен только на уровнеdecide(), но не сквозь pipeline.- Правило
ignoreна мерчанта → статусignored, AI не вызывался (onCall-флаг). - Дедупликация: два
insertIncomingс одинаковымиpackageName+body→ второе сообщение не порождает второй обработки/транзакции (проверить фактический контрактinsertIncoming: возвращает существующее? вставляет со статусомduplicate? — пиннить реальное поведение). - Дневной лимит токенов исчерпан (
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
- Дефолты:
enabled=true,autoApply=true,aiConsent=false, модель =kDefaultAiModel. addTokenUsageаккумулирует;tokensUsedTodayв state обновляется.isDailyLimitReached: false без лимита; true приused >= limit.- Смена дня: ключ счётчика содержит дату (
ai_token_usage_YYYY-MM-DD).DateTime.now()не инжектится — честно протестировать «вчерашний счётчик не читается» можно, записав преференс с вчерашним ключом напрямую черезsettingsDao.setPreference('ai_token_usage_<вчера>', '999')и проверив, чтоbuild()вернул 0. (Опционально: рефакторинг_tokenUsageKeyна инжектируемые часы — отдельным решением.) - Персистентность: значения переживают пересоздание контейнера над той же БД.
Этап 5. Accounts / Categories / UserSeeder
5a. AccountRepositoryImpl (in-memory БД)
Новый тест: test/features/accounts/repository/account_repository_test.dart
- CRUD round-trip: create → findById с полями (currency, iconCode, colorValue, initialBalance).
setDefault: инвариант единственного дефолта. Два счёта,setDefault(a1), затемsetDefault(a2)→isDefaultтолько у a2. Это опора account resolver'а (globalDefault, score 40) и gate-проверкиaccountTrusted— самый ценный кейс этапа.setDefault(null, userId)снимает дефолт со всех.archive: архивный счёт исчезает из основного watch-потока (или помечается — по фактическому контракту DAO), но находится по id.- Изоляция пользователей: счета другого
userIdне видны.
5b. CategoryRepositoryImpl
Новый тест: test/features/categories/repository/category_repository_test.dart
- CRUD round-trip, включая
parentId(подкатегория) и его обнуление. - Фильтрация по
type(expense/income) — то, на что завязана форма транзакции. archive+ изоляция пользователей (аналогично счетам).
5c. UserSeeder (реальный, in-memory БД)
Новый тест: test/features/user/user_seeder_test.dart
Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом от него зависит первый запуск приложения.
seedForNewUser(userId)→ созданы дефолтные счета и категории (количества > 0; точные наборы не пиннить — они будут меняться), все с правильнымuserId.- Один счёт помечен
isDefault(если это контракт сидера — проверить по коду). - Демо-транзакции созданы и ссылаются на посеянные счета/категории (FK-цепочка цела). Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md) достаточно было удалить один блок ассертов.
- Повторный вызов для того же пользователя: пиннить фактическое поведение (дубли? идемпотентность?) — это поведение при «втором онбординге».
5d. Контроллеры accounts/categories — только если в них есть логика
Если AccountsController/CategoriesController лишь проксируют репозиторий —
отдельные тесты не нужны (паттерн уже покрыт users_controller_test). Тестировать
только если есть валидация/оркестрация (проверить по коду перед написанием).
Этап 6. Мелкие улучшения существующих тестов
onboarding_screen_test.dart: заменить литералы'Welcome','Continue','Your name'на строки изAppLocalizations.delegate.load(const Locale('en'))— по образцуinbox_card_test.dart:139. Иначе правка ARB валит тесты с невнятной ошибкой.- Общие фейки:
FakeTransactionRepositoryсуществует в трёх вариантах (transactions_controller, inbox_controller, форма). Вынести один полный вtest/support/fakes.dart, остальные удалить. Туда же —_UnusedXxxRepo-заглушки. widget_test.dart: добавить ассерт, что без активного пользователя показанOnboardingScreen(проверка redirect-логики роутера почти бесплатно).usersStreamProvider-тест (users_controller_test.dart:332): сейчас фейковыйwatchAll()возвращает одноразовыйStream.value, и реактивность не проверяется. Заменить в фейке наStreamController.broadcastс ре-эмитом послеcreate— тогда тест начнёт проверять то, что декларирует.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, повторный сид), помечены комментарием
// Пиннит текущее поведение: ...— чтобы при осознанном изменении поведения их меняли, а не «чинили».