Files
OnBudget/test/features/notification_parsing/parser/rule_lookup_test.dart
T
SandersandClaude Opus 4.8 ab66aebc5e Switch AI notification parsing from OpenRouter to DeepSeek API
Replace the OpenRouter HTTP client with a DeepSeek client:
- data/openrouter/ -> data/deepseek/; OpenRouterClient -> DeepSeekClient
  (+ DeepSeekCompletion / DeepSeek*Exception)
- base URL api.deepseek.com, drop OpenRouter-only headers
- DeepSeek has no strict json_schema: use response_format json_object
  and rely on the prompt for shape (ai_schema.dart removed)
- secure-storage key openrouter_api_key -> deepseek_api_key
- default model deepseek/deepseek-v4-flash -> deepseek-chat
- l10n strings, test dart-defines (DEEPSEEK_API_KEY/_TEST_MODEL), docs

Also bundles in-progress findBodyIgnoreRule (body-level ignore rules
applied before the AI call) already present in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:23:38 +03:00

225 lines
5.9 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/rule_lookup.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
ParseRule _rule({
required String id,
required String pattern,
MatchMode mode = MatchMode.contains,
ParseRuleKind kind = ParseRuleKind.merchantToCategory,
bool enabled = true,
int matchCount = 0,
int priority = 0,
String? categoryId = 'c1',
}) {
return ParseRule(
id: id,
userId: 'u1',
kind: kind,
matchMode: mode,
pattern: pattern,
enabled: enabled,
matchCount: matchCount,
priority: priority,
categoryId: categoryId,
createdAt: DateTime(2026, 1, 1),
);
}
void main() {
group('patternMatches', () {
test('contains is case-insensitive over body', () {
expect(
patternMatches('pyaterochka', MatchMode.contains,
body: 'Покупка PYATEROCHKA 1240'),
isTrue,
);
});
test('exact matches normalized merchant only', () {
expect(
patternMatches('pyaterochka', MatchMode.exact,
body: 'long body', merchantRaw: 'PYATEROCHKA'),
isTrue,
);
expect(
patternMatches('pyat', MatchMode.exact,
body: 'long body', merchantRaw: 'PYATEROCHKA'),
isFalse,
);
});
test('regex match, invalid regex → false', () {
expect(patternMatches(r'\d{4}', MatchMode.regex, body: 'Карта 3456'),
isTrue);
expect(patternMatches('[invalid(', MatchMode.regex, body: 'x'), isFalse);
});
test('empty pattern → false', () {
expect(patternMatches(' ', MatchMode.contains, body: 'anything'),
isFalse);
});
});
group('findMerchantRule', () {
const body = 'Покупка PYATEROCHKA 1240';
test('returns matching enabled merchant rule', () {
final r = findMerchantRule(
[_rule(id: 'a', pattern: 'PYATEROCHKA')],
body: body,
);
expect(r?.id, 'a');
});
test('ignores disabled rules', () {
final r = findMerchantRule(
[_rule(id: 'a', pattern: 'PYATEROCHKA', enabled: false)],
body: body,
);
expect(r, isNull);
});
test('ignores ignore-kind rules', () {
final r = findMerchantRule(
[_rule(id: 'a', pattern: 'PYATEROCHKA', kind: ParseRuleKind.ignore)],
body: body,
);
expect(r, isNull);
});
test('conflict: longer pattern wins', () {
final r = findMerchantRule(
[
_rule(id: 'short', pattern: 'PYAT'),
_rule(id: 'long', pattern: 'PYATEROCHKA'),
],
body: body,
);
expect(r?.id, 'long');
});
test('conflict: equal length → higher matchCount wins', () {
final r = findMerchantRule(
[
_rule(id: 'a', pattern: 'PYATEROCHKA', matchCount: 1),
_rule(id: 'b', pattern: 'PYATEROCHKX', matchCount: 9),
],
body: 'PYATEROCHKA PYATEROCHKX',
);
expect(r?.id, 'b');
});
});
group('findSenderRule', () {
const body = 'Покупка PYATEROCHKA 1240';
ParseRule sender({
required String id,
required String pattern,
String? accountId = 'acc1',
bool enabled = true,
}) =>
_rule(
id: id,
pattern: pattern,
kind: ParseRuleKind.senderToAccount,
enabled: enabled,
categoryId: null,
).copyWith(accountId: accountId);
test('returns matching enabled sender rule', () {
final r = findSenderRule(
[sender(id: 's', pattern: 'PYATEROCHKA')],
body: body,
);
expect(r?.id, 's');
});
test('ignores rules without accountId', () {
final r = findSenderRule(
[sender(id: 's', pattern: 'PYATEROCHKA', accountId: null)],
body: body,
);
expect(r, isNull);
});
test('ignores merchant-kind rules', () {
final r = findSenderRule(
[_rule(id: 'm', pattern: 'PYATEROCHKA')],
body: body,
);
expect(r, isNull);
});
});
group('findIgnoreRule', () {
test('finds enabled ignore rule', () {
final r = findIgnoreRule(
[_rule(id: 'i', pattern: 'спам', kind: ParseRuleKind.ignore)],
body: 'это спам реклама',
);
expect(r?.id, 'i');
});
});
group('findBodyIgnoreRule', () {
test('matches contains ignore rule by body (pre-AI)', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'i',
pattern: 'Доставлен заказ',
kind: ParseRuleKind.ignore),
],
'Доставлен заказ №123',
);
expect(r?.id, 'i');
});
test('matches regex ignore rule by body', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'i',
pattern: r'заказ \d+',
mode: MatchMode.regex,
kind: ParseRuleKind.ignore),
],
'Доставлен заказ 123',
);
expect(r?.id, 'i');
});
test('skips exact ignore rules (need merchant, only known after AI)', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'i',
pattern: 'Доставлен заказ №123',
mode: MatchMode.exact,
kind: ParseRuleKind.ignore),
],
'Доставлен заказ №123',
);
expect(r, isNull);
});
test('skips disabled and non-ignore rules', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'a',
pattern: 'спам',
kind: ParseRuleKind.ignore,
enabled: false),
_rule(id: 'b', pattern: 'спам', kind: ParseRuleKind.merchantToCategory),
],
'это спам',
);
expect(r, isNull);
});
});
}