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>
595 lines
21 KiB
Dart
595 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_riverpod/misc.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:new_budget/l10n/app_localizations.dart';
|
|
import 'package:new_budget/src/app/theme/app_theme.dart';
|
|
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
|
|
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
|
|
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
|
|
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/domain/entities/rule_suggestion.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
|
|
import 'package:new_budget/src/features/notification_parsing/presentation/widgets/inbox_card.dart';
|
|
import 'package:new_budget/src/features/transactions/presentation/screens/transaction_form_screen.dart';
|
|
|
|
final _now = DateTime(2026, 5, 29, 14, 5);
|
|
|
|
class FakeInboxController extends InboxController {
|
|
int createRuleCalls = 0;
|
|
int confirmOnceCalls = 0;
|
|
int ignoreCalls = 0;
|
|
int retryCalls = 0;
|
|
String? lastCreateRuleCategory;
|
|
String? lastCreateRuleAccount;
|
|
String? lastConfirmCategory;
|
|
String? lastConfirmAccount;
|
|
final List<(String, String)> appliedCalls = [];
|
|
|
|
@override
|
|
AsyncValue<void> build() => const AsyncData(null);
|
|
|
|
/// Что возвращать из createRule/confirmOnce: true = «дефолт приложения
|
|
/// только что выучен» → карточка показывает SnackBar.
|
|
bool learnResult = false;
|
|
|
|
@override
|
|
Future<bool> createRule({
|
|
required String userId,
|
|
required RawMessage message,
|
|
required ParseDraft draft,
|
|
required String accountId,
|
|
String? categoryId,
|
|
required String merchantCanonical,
|
|
required String pattern,
|
|
MatchMode matchMode = MatchMode.contains,
|
|
bool learnAppDefault = true,
|
|
}) async {
|
|
createRuleCalls++;
|
|
lastCreateRuleCategory = categoryId;
|
|
lastCreateRuleAccount = accountId;
|
|
return learnResult;
|
|
}
|
|
|
|
@override
|
|
Future<bool> confirmOnce({
|
|
required String userId,
|
|
required RawMessage message,
|
|
required ParseDraft draft,
|
|
required String accountId,
|
|
String? categoryId,
|
|
bool learnAppDefault = true,
|
|
}) async {
|
|
confirmOnceCalls++;
|
|
lastConfirmCategory = categoryId;
|
|
lastConfirmAccount = accountId;
|
|
return learnResult;
|
|
}
|
|
|
|
@override
|
|
Future<void> markApplied(RawMessage message, String transactionId) async {
|
|
appliedCalls.add((message.id, transactionId));
|
|
}
|
|
|
|
@override
|
|
Future<void> ignore(RawMessage message) async {
|
|
ignoreCalls++;
|
|
}
|
|
|
|
@override
|
|
Future<void> retry(RawMessage message) async {
|
|
retryCalls++;
|
|
}
|
|
}
|
|
|
|
/// Сообщение, застрявшее в «ждёт сети» (pendingAi): draftJson ещё нет.
|
|
RawMessage _pendingAiMessage({int attempts = 0, String? lastParseError}) =>
|
|
RawMessage(
|
|
id: 'msg3',
|
|
userId: 'u1',
|
|
packageName: 'ru.sberbankmobile',
|
|
body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
|
|
receivedAt: _now,
|
|
dedupHash: 'h3',
|
|
status: RawMessageStatus.pendingAi,
|
|
parseAttemptCount: attempts,
|
|
lastParseError: lastParseError,
|
|
createdAt: _now,
|
|
);
|
|
|
|
RawMessage _recognizedMessage() {
|
|
final draft = ParseDraft(
|
|
rawMessageId: 'msg1',
|
|
type: TransactionType.expense,
|
|
amount: 124000,
|
|
cardLast4: '3456',
|
|
merchantRaw: 'PYATEROCHKA',
|
|
merchantCanonical: 'PYATEROCHKA',
|
|
categoryId: 'cat1',
|
|
kind: TxKind.purchase,
|
|
source: ParseSource.regex,
|
|
);
|
|
// Знакомый мерчант → есть suggestion → карточка предлагает правило.
|
|
const sugg = RuleSuggestion(
|
|
merchantRaw: 'PYATEROCHKA',
|
|
merchantCanonical: 'PYATEROCHKA',
|
|
categoryId: 'cat1',
|
|
categoryName: 'Продукты',
|
|
);
|
|
return RawMessage(
|
|
id: 'msg1',
|
|
userId: 'u1',
|
|
packageName: 'ru.sberbankmobile',
|
|
body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
|
|
receivedAt: _now,
|
|
dedupHash: 'h',
|
|
status: RawMessageStatus.inbox,
|
|
draftJson: encodeDraftBundle(draft, sugg),
|
|
createdAt: _now,
|
|
);
|
|
}
|
|
|
|
/// Уведомление без мерчанта (только сумма) → нет suggestion → карточка
|
|
/// показывает «Подтвердить» без кнопки «Создать правило».
|
|
/// [categorySuggestion] — AI-подсказка категории (по имени), которую карточка
|
|
/// должна предзаполнить в пикере.
|
|
RawMessage _amountOnlyMessage({String? categorySuggestion}) {
|
|
final draft = ParseDraft(
|
|
rawMessageId: 'msg2',
|
|
type: TransactionType.expense,
|
|
amount: 50000,
|
|
kind: TxKind.purchase,
|
|
categorySuggestion: categorySuggestion,
|
|
source: ParseSource.ai,
|
|
);
|
|
return RawMessage(
|
|
id: 'msg2',
|
|
userId: 'u1',
|
|
packageName: 'com.example.wallet',
|
|
body: 'Списание 500 ₽',
|
|
receivedAt: _now,
|
|
dedupHash: 'h2',
|
|
status: RawMessageStatus.inbox,
|
|
draftJson: encodeDraftBundle(draft, null),
|
|
createdAt: _now,
|
|
);
|
|
}
|
|
|
|
const _categoryById = {
|
|
'cat1': Category(
|
|
id: 'cat1',
|
|
userId: 'u1',
|
|
name: 'Продукты',
|
|
type: CategoryType.expense,
|
|
archived: false,
|
|
),
|
|
};
|
|
|
|
/// Герметичные overrides по умолчанию: чипы «частых категорий» — пустой стрим
|
|
/// (иначе провайдер полез бы в реальную БД).
|
|
List<Override> _baseOverrides(
|
|
FakeInboxController fake, {
|
|
List<String> topCategories = const [],
|
|
}) {
|
|
return [
|
|
inboxControllerProvider.overrideWith(() => fake),
|
|
topConfirmCategoriesProvider(
|
|
'u1', 'com.example.wallet', TransactionType.expense)
|
|
.overrideWith((ref) => Stream.value(topCategories)),
|
|
topConfirmCategoriesProvider(
|
|
'u1', 'ru.sberbankmobile', TransactionType.expense)
|
|
.overrideWith((ref) => Stream.value(topCategories)),
|
|
];
|
|
}
|
|
|
|
Widget _host(
|
|
FakeInboxController fake,
|
|
RawMessage message, {
|
|
String? defaultAccountId = 'acc1',
|
|
List<String> topCategories = const [],
|
|
List<Override> extraOverrides = const [],
|
|
}) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
..._baseOverrides(fake, topCategories: topCategories),
|
|
...extraOverrides,
|
|
],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
locale: const Locale('ru'),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: Scaffold(
|
|
body: InboxCard(
|
|
message: message,
|
|
userId: 'u1',
|
|
defaultAccountId: defaultAccountId,
|
|
categoryById: _categoryById,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
setUpAll(() {
|
|
GoogleFonts.config.allowRuntimeFetching = false;
|
|
});
|
|
|
|
late FakeInboxController fake;
|
|
setUp(() => fake = FakeInboxController());
|
|
|
|
testWidgets('renders merchant, category and amount', (tester) async {
|
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
|
await tester.pump();
|
|
|
|
expect(find.text('PYATEROCHKA'), findsOneWidget);
|
|
expect(find.textContaining('Продукты'), findsWidgets);
|
|
expect(find.textContaining('240'), findsWidgets); // money formatted
|
|
});
|
|
|
|
testWidgets('tap "create rule" calls controller with category', (tester) async {
|
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
|
await tester.pump();
|
|
|
|
// Главная часть кнопки = ярлык «Правило: … → …». Категория задана →
|
|
// createRule вызывается напрямую, без открытия редактора.
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
|
|
await tester.pump();
|
|
|
|
expect(fake.createRuleCalls, 1);
|
|
expect(fake.lastCreateRuleCategory, 'cat1');
|
|
});
|
|
|
|
testWidgets('tap confirm calls controller', (tester) async {
|
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byIcon(Icons.check));
|
|
await tester.pump();
|
|
|
|
expect(fake.confirmOnceCalls, 1);
|
|
expect(fake.createRuleCalls, 0);
|
|
});
|
|
|
|
testWidgets('confirm показывает SnackBar, когда контроллер выучил дефолт',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
fake.learnResult = true;
|
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byIcon(Icons.check));
|
|
await tester.pump();
|
|
|
|
expect(
|
|
find.descendant(
|
|
of: find.byType(SnackBar),
|
|
matching: find.text(l10n.inboxAppDefaultSet),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('confirm без обучения дефолта — SnackBar не показывается',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byIcon(Icons.check));
|
|
await tester.pump();
|
|
|
|
expect(find.text(l10n.inboxAppDefaultSet), findsNothing);
|
|
});
|
|
|
|
testWidgets('tap ignore calls controller', (tester) async {
|
|
await tester.pumpWidget(_host(fake, _recognizedMessage()));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pump();
|
|
|
|
expect(fake.ignoreCalls, 1);
|
|
});
|
|
|
|
testWidgets('amount-only message offers confirm, not a rule', (tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(_host(fake, _amountOnlyMessage()));
|
|
await tester.pump();
|
|
|
|
// Нет мерчанта → кнопки «Создать правило» нет, есть «Подтвердить».
|
|
expect(find.textContaining('Создать правило'), findsNothing);
|
|
expect(find.text(l10n.inboxConfirm), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('confirm inactive without category, hint is shown',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(_host(fake, _amountOnlyMessage()));
|
|
await tester.pump();
|
|
|
|
// Категория не выбрана → подсказка видна, тап по «Подтвердить» — ничего.
|
|
expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget);
|
|
|
|
await tester.tap(find.text(l10n.inboxConfirm));
|
|
await tester.pump();
|
|
expect(fake.confirmOnceCalls, 0);
|
|
});
|
|
|
|
testWidgets('confirm-once pre-fills category from AI hint and passes it',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(
|
|
_host(fake, _amountOnlyMessage(categorySuggestion: 'Продукты')));
|
|
await tester.pump();
|
|
|
|
// AI-подсказка «Продукты» сматчена на cat1 и показана в пикере.
|
|
expect(find.text('Продукты'), findsOneWidget);
|
|
// Категория есть → подсказки «выберите категорию» нет.
|
|
expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing);
|
|
|
|
await tester.tap(find.text(l10n.inboxConfirm));
|
|
await tester.pump();
|
|
|
|
expect(fake.confirmOnceCalls, 1);
|
|
expect(fake.lastConfirmCategory, 'cat1');
|
|
});
|
|
|
|
testWidgets('category chip selects category and is passed to confirmOnce',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(_host(
|
|
fake,
|
|
_amountOnlyMessage(),
|
|
topCategories: const ['cat1'],
|
|
));
|
|
await tester.pump();
|
|
|
|
// Чип «Продукты» виден; категория ещё не выбрана.
|
|
expect(find.text('Продукты'), findsOneWidget);
|
|
expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Продукты').first);
|
|
await tester.pump();
|
|
|
|
// Выбор чипа активирует «Подтвердить».
|
|
expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing);
|
|
|
|
await tester.tap(find.text(l10n.inboxConfirm));
|
|
await tester.pump();
|
|
|
|
expect(fake.confirmOnceCalls, 1);
|
|
expect(fake.lastConfirmCategory, 'cat1');
|
|
});
|
|
|
|
testWidgets('create rule without account opens the account picker',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
final account = Account(
|
|
id: 'acc-x',
|
|
userId: 'u1',
|
|
name: 'Основной',
|
|
type: AccountType.card,
|
|
currency: 'RUB',
|
|
initialBalance: 0,
|
|
archived: false,
|
|
createdAt: _now,
|
|
);
|
|
// Нет дефолтного счёта и draft.accountId == null → «Создать правило»
|
|
// открывает пикер счёта вместо молчаливого no-op.
|
|
await tester.pumpWidget(_host(
|
|
fake,
|
|
_recognizedMessage(),
|
|
defaultAccountId: null,
|
|
extraOverrides: [
|
|
accountsStreamProvider('u1')
|
|
.overrideWith((ref) => Stream.value([account])),
|
|
accountBalanceProvider('acc-x')
|
|
.overrideWith((ref) => Stream.value(0)),
|
|
],
|
|
));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Открылся пикер счёта, правило ещё не создано.
|
|
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
|
|
expect(fake.createRuleCalls, 0);
|
|
|
|
// Имя счёта в тайле пикера встречается дважды (название + подпись).
|
|
await tester.tap(find.text('Основной').first);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fake.createRuleCalls, 1);
|
|
expect(fake.lastCreateRuleAccount, 'acc-x');
|
|
expect(fake.lastCreateRuleCategory, 'cat1');
|
|
});
|
|
|
|
testWidgets('create rule: cancelling the account picker does nothing',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(_host(
|
|
fake,
|
|
_recognizedMessage(),
|
|
defaultAccountId: null,
|
|
extraOverrides: [
|
|
accountsStreamProvider('u1')
|
|
.overrideWith((ref) => Stream.value(const [])),
|
|
],
|
|
));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
|
|
|
|
// Тап по барьеру закрывает пикер без выбора.
|
|
await tester.tapAt(const Offset(20, 20));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fake.createRuleCalls, 0);
|
|
});
|
|
|
|
testWidgets('confirm without account opens the account picker',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
final account = Account(
|
|
id: 'acc-x',
|
|
userId: 'u1',
|
|
name: 'Основной',
|
|
type: AccountType.card,
|
|
currency: 'RUB',
|
|
initialBalance: 0,
|
|
archived: false,
|
|
createdAt: _now,
|
|
);
|
|
// Нет дефолтного счёта и draft.accountId == null → «Подтвердить» открывает
|
|
// пикер счёта; выбор в нём завершает подтверждение.
|
|
await tester.pumpWidget(_host(
|
|
fake,
|
|
_amountOnlyMessage(categorySuggestion: 'Продукты'),
|
|
defaultAccountId: null,
|
|
extraOverrides: [
|
|
accountsStreamProvider('u1')
|
|
.overrideWith((ref) => Stream.value([account])),
|
|
accountBalanceProvider('acc-x')
|
|
.overrideWith((ref) => Stream.value(0)),
|
|
],
|
|
));
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text(l10n.inboxConfirm));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Открылся пикер счёта.
|
|
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
|
|
expect(fake.confirmOnceCalls, 0);
|
|
|
|
// Имя счёта в тайле пикера встречается дважды (название + подпись).
|
|
await tester.tap(find.text('Основной').first);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fake.confirmOnceCalls, 1);
|
|
expect(fake.lastConfirmAccount, 'acc-x');
|
|
});
|
|
|
|
testWidgets('pencil opens full form with prefill and links the saved tx',
|
|
(tester) async {
|
|
TransactionFormPrefill? capturedPrefill;
|
|
final router = GoRouter(
|
|
routes: [
|
|
GoRoute(
|
|
path: '/',
|
|
builder: (context, state) => Scaffold(
|
|
body: InboxCard(
|
|
message: _amountOnlyMessage(),
|
|
userId: 'u1',
|
|
defaultAccountId: 'acc1',
|
|
categoryById: _categoryById,
|
|
),
|
|
),
|
|
),
|
|
GoRoute(
|
|
path: '/transactions/new',
|
|
builder: (context, state) {
|
|
capturedPrefill = state.extra as TransactionFormPrefill?;
|
|
return Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => context.pop('tx-9'),
|
|
child: const Text('SAVE-STUB'),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
|
|
await tester.pumpWidget(ProviderScope(
|
|
overrides: _baseOverrides(fake),
|
|
child: MaterialApp.router(
|
|
routerConfig: router,
|
|
theme: AppTheme.light(),
|
|
locale: const Locale('ru'),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
),
|
|
));
|
|
await tester.pump();
|
|
|
|
// Карандаш активен даже без категории.
|
|
await tester.tap(find.byIcon(Icons.edit_outlined));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(capturedPrefill, isNotNull);
|
|
expect(capturedPrefill!.amountMinor, 50000);
|
|
expect(capturedPrefill!.type, TransactionType.expense);
|
|
expect(capturedPrefill!.accountId, 'acc1');
|
|
expect(capturedPrefill!.rawMessageId, 'msg2');
|
|
|
|
// «Сохранение» в форме возвращает id → сообщение линкуется.
|
|
await tester.tap(find.text('SAVE-STUB'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fake.appliedCalls, contains(('msg2', 'tx-9')));
|
|
expect(fake.confirmOnceCalls, 0);
|
|
});
|
|
|
|
testWidgets('unrecognized message shows manual-add fallback', (tester) async {
|
|
final msg = _recognizedMessage().copyWith(draftJson: null);
|
|
await tester.pumpWidget(_host(fake, msg));
|
|
await tester.pump();
|
|
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
expect(find.text(l10n.inboxUnrecognized), findsOneWidget);
|
|
expect(find.text(l10n.inboxAddManually), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('pendingAi offline: причина «нет сети», ретрай зовёт контроллер',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(_host(
|
|
fake,
|
|
_pendingAiMessage(lastParseError: 'offline'),
|
|
extraOverrides: [
|
|
// connectivity_plus не работает под flutter test — оверрайдим явно.
|
|
isOnlineProvider.overrideWith((ref) => Stream.value(false)),
|
|
],
|
|
));
|
|
await tester.pump();
|
|
|
|
expect(find.text(l10n.inboxWaitingNetworkTitle), findsOneWidget);
|
|
expect(find.text(l10n.inboxStuckOffline), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.refresh));
|
|
await tester.pump();
|
|
expect(fake.retryCalls, 1);
|
|
});
|
|
|
|
testWidgets('pendingAi при живой сети: причина «запрос не прошёл» + попытки',
|
|
(tester) async {
|
|
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
|
|
await tester.pumpWidget(_host(
|
|
fake,
|
|
_pendingAiMessage(attempts: 2, lastParseError: 'network'),
|
|
extraOverrides: [
|
|
isOnlineProvider.overrideWith((ref) => Stream.value(true)),
|
|
],
|
|
));
|
|
await tester.pump();
|
|
|
|
expect(find.text(l10n.inboxWaitingNetworkTitle), findsOneWidget);
|
|
expect(find.text(l10n.inboxStuckNetwork), findsOneWidget);
|
|
expect(find.text(l10n.parsingDetailAttempt(2, 5)), findsOneWidget);
|
|
});
|
|
}
|