Files
OnBudget/test/features/notification_parsing/parser/decision_gate_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

238 lines
7.0 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/account_resolver.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/decision_gate.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
ParseRule _rule({
TransactionType? txTypeGuard = TransactionType.expense,
bool autoApply = true,
}) =>
ParseRule(
id: 'r1',
userId: 'u1',
matchMode: MatchMode.contains,
pattern: 'PYATEROCHKA',
txTypeGuard: txTypeGuard,
autoApply: autoApply,
categoryId: 'c1',
createdAt: DateTime(2026, 1, 1),
);
RawMessage _message({String body = 'PYATEROCHKA Покупка 1 240 ₽'}) =>
RawMessage(
id: 'm1',
userId: 'u1',
packageName: 'com.bank',
body: body,
receivedAt: DateTime(2026, 1, 2),
dedupHash: 'h1',
status: RawMessageStatus.pending,
createdAt: DateTime(2026, 1, 2),
);
ParseDraft _draft({
int amount = 124000,
TransactionType type = TransactionType.expense,
String currency = 'RUB',
String? accountId = 'a1',
}) =>
ParseDraft(
rawMessageId: 'm1',
type: type,
amount: amount,
currency: currency,
merchantRaw: 'PYATEROCHKA',
accountId: accountId,
source: ParseSource.ai,
);
const _trusted = AccountResolution(
accountId: 'a1',
score: 90,
trusted: true,
source: AccountSource.accountRule,
);
const _untrusted = AccountResolution(
accountId: 'a1',
score: 40,
trusted: false,
source: AccountSource.globalDefault,
);
void main() {
group('decide', () {
test('rule + amount in body + trusted account → autoApply', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.autoApply);
expect(r.failed, isEmpty);
});
test('toggle off → inbox without evaluating checks', () {
final r = decide(
autoApplyEnabled: false,
merchantRule: _rule(),
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, isEmpty);
});
test('no rule → inbox with ruleMatched failed', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: null,
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.ruleMatched});
});
test('amount not found in body → inbox', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(amount: 99900),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.amountVerifiedInBody});
});
test('empty currency → inbox', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(currency: ''),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.currencyKnown});
});
test('draft type differs from rule txTypeGuard → inbox', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(txTypeGuard: TransactionType.expense),
draft: _draft(type: TransactionType.income),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.typeMatchesRule});
});
test('rule without txTypeGuard → type check skipped, autoApply', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(txTypeGuard: null),
draft: _draft(type: TransactionType.income),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.autoApply);
});
test('rule with autoApply=false → inbox with ruleAutoApplyEnabled', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(autoApply: false),
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.ruleAutoApplyEnabled});
});
test('rule with autoApply=true → no ruleAutoApplyEnabled in failed', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(r.failed, isNot(contains(AutoApplyCheck.ruleAutoApplyEnabled)));
});
test('no account → inbox even with rule', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(accountId: null),
message: _message(),
resolution: const AccountResolution(
accountId: null,
score: 15,
trusted: false,
source: AccountSource.none,
),
);
expect(r.decision, GateDecision.inbox);
expect(
r.failed,
{AutoApplyCheck.accountResolved, AutoApplyCheck.accountTrusted},
);
});
test('untrusted account (global default) → inbox', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(),
message: _message(),
resolution: _untrusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.accountTrusted});
});
test('huge amount → inbox even with rule', () {
const amount = 100001 * 100;
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(amount: amount),
message: _message(body: 'PYATEROCHKA Покупка 100 001 ₽'),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.amountUnderCap});
});
test('collects all failed checks, not just the first', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: null,
draft: _draft(amount: 99900, currency: ''),
message: _message(),
resolution: _untrusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {
AutoApplyCheck.ruleMatched,
AutoApplyCheck.amountVerifiedInBody,
AutoApplyCheck.currencyKnown,
AutoApplyCheck.accountTrusted,
});
});
});
}