- 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>
211 lines
6.4 KiB
Dart
211 lines
6.4 KiB
Dart
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');
|
||
});
|
||
}
|