Notification parsing: - Drop the account_bindings table/DAO/repo/entity/controller/screen; account resolution now goes senderToAccount rule -> source_apps.defaultAccountId (trusted) -> global default (untrusted -> Inbox), via v1->v2 migration. - Add defaultAccountId to source_apps; per-app settings consolidated into source_app_detail_screen (/settings/parsing/apps/:pkg). - Inbox auto-learns an app default on first Confirm/CreateRule; account picker on the card instead of a disabled button; parse_error_labels extracted. Analytics: - Replace placeholder screen with fl_chart cards (chart_card, chart_theme, month_stepper, month_math domain helper); slim down habit_analysis_screen. Android: add launcher icon (adaptive foreground + colors.xml) and app_name. Tests: migration_v2, analytics (screen/month_math), AI retry, inbox visibility; update resolver/gate/inbox suites for the new resolution path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
6.2 KiB
Dart
212 lines
6.2 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? txType = TransactionType.expense}) =>
|
|
ParseRule(
|
|
id: 'r1',
|
|
userId: 'u1',
|
|
kind: ParseRuleKind.merchantToCategory,
|
|
matchMode: MatchMode.contains,
|
|
pattern: 'PYATEROCHKA',
|
|
txType: txType,
|
|
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.senderRule,
|
|
);
|
|
|
|
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 txType → inbox', () {
|
|
final r = decide(
|
|
autoApplyEnabled: true,
|
|
merchantRule: _rule(txType: TransactionType.expense),
|
|
draft: _draft(type: TransactionType.income),
|
|
message: _message(),
|
|
resolution: _trusted,
|
|
);
|
|
expect(r.decision, GateDecision.inbox);
|
|
expect(r.failed, {AutoApplyCheck.typeMatchesRule});
|
|
});
|
|
|
|
test('legacy rule without txType → type check skipped, autoApply', () {
|
|
final r = decide(
|
|
autoApplyEnabled: true,
|
|
merchantRule: _rule(txType: null),
|
|
draft: _draft(type: TransactionType.income),
|
|
message: _message(),
|
|
resolution: _trusted,
|
|
);
|
|
expect(r.decision, GateDecision.autoApply);
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
}
|