Files
OnBudget/test/features/notification_parsing/data/parse_rules_dedup_test.dart
T
SandersandClaude Fable 5 f2ed501ed4 Unify parse rules: one rule = condition + actions, autoApply toggle, v3 migration
- Drop ParseRuleKind from parse_rules (enum survives only in rule_candidates):
  a rule is now a condition (pattern + matchMode + optional txTypeGuard) plus
  any set of actions (merchantCanonical/categoryId/accountId) or isIgnore
- Field-wise resolution: findClassificationRule fills merchant/category,
  findAccountRule feeds the account resolver - one message may use both
- Per-rule autoApply toggle + ruleAutoApplyEnabled gate check; off -> Inbox
  with full prefill
- Dedup guard in ParseRulesRepositoryImpl.create: same condition updates or
  reactivates the existing row instead of inserting a duplicate
- ParsingPipeline.reapplyRulesToInbox: sweep app inbox cards on cached AI
  drafts after createRule/ignoreWithRule (zero tokens)
- Schema v2 -> v3 migration (drop kind, add auto_apply) + migration_v3_test
- Rework rule_editor_screen into a single unified form; update rules list,
  rule cards, source app detail, l10n strings, and tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:24:06 +03:00

211 lines
6.4 KiB
Dart
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.
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
/// Dedup guard в [ParseRulesRepositoryImpl.create]: «одно условие = одно
/// правило». Дубль по условию (pattern без регистра/пробелов + matchMode +
/// packageName, NULL у существующего = глобальное легаси) обновляет
/// существующую строку вместо вставки конкурента.
const _userId = 'u1';
const _bank = 'ru.sberbankmobile';
void main() {
late AppDatabase db;
late ParseRulesRepositoryImpl repo;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Test'),
);
repo = ParseRulesRepositoryImpl(db.parseRulesDao);
});
tearDown(() => db.close());
Future<int> rowCount() async =>
(await db.select(db.parseRulesTable).get()).length;
test('повторный create с тем же условием → одна строка, действия обновлены',
() async {
final first = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
final second = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-2',
merchantCanonical: 'Лента',
);
expect(await rowCount(), 1);
expect(second.id, first.id, reason: 'обновляется существующая строка');
expect(second.categoryId, 'cat-2');
expect(second.merchantCanonical, 'Лента');
});
test('паттерн с другим регистром/пробелами → это дубль', () async {
final first = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
final second = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: ' lenta ',
categoryId: 'cat-2',
);
expect(await rowCount(), 1);
expect(second.id, first.id);
expect(second.categoryId, 'cat-2');
});
test('disabled-дубль реактивируется', () async {
final first = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
await repo.setEnabled(first.id, enabled: false);
final second = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
expect(await rowCount(), 1);
expect(second.enabled, isTrue);
expect((await repo.findById(first.id))!.enabled, isTrue);
});
test('другой matchMode → отдельная строка', () async {
await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.exact,
pattern: 'LENTA',
categoryId: 'cat-1',
);
expect(await rowCount(), 2);
});
test('другое приложение → отдельная строка', () async {
await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
await repo.create(
userId: _userId,
packageName: 'com.other.bank',
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
expect(await rowCount(), 2);
});
test('легаси-правило без packageName — дубль для любого приложения',
() async {
// Легаси-строку создаём напрямую через DAO (create требует packageName).
await db.parseRulesDao.insert(ParseRulesTableCompanion.insert(
id: 'legacy-1',
userId: _userId,
pattern: 'LENTA',
matchMode: const Value(MatchMode.contains),
categoryId: const Value('cat-old'),
));
final updated = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-new',
);
expect(await rowCount(), 1);
expect(updated.id, 'legacy-1');
expect(updated.categoryId, 'cat-new');
});
test('ignore поверх правила категории → правило стало ignore', () async {
final first = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'Доставлен заказ',
categoryId: 'cat-1',
);
final second = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'Доставлен заказ',
isIgnore: true,
);
expect(await rowCount(), 1);
expect(second.id, first.id);
expect(second.isIgnore, isTrue);
expect(second.categoryId, isNull,
reason: 'ignore взаимоисключим с действиями — категория снята');
});
test('txTypeGuard и autoApply обновляются при дедупе', () async {
await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
txTypeGuard: TransactionType.expense,
);
final second = await repo.create(
userId: _userId,
packageName: _bank,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
autoApply: false,
);
expect(second.autoApply, isFalse);
expect(second.txTypeGuard, isNull,
reason: 'условие обновляется целиком значениями нового create');
});
}