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>
147 lines
5.8 KiB
Dart
147 lines
5.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.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/analytics/application/habit_analysis_providers.dart';
|
|
import 'package:new_budget/src/features/analytics/presentation/screens/habit_analysis_screen.dart';
|
|
import 'package:new_budget/src/features/categories/application/categories_controller.dart';
|
|
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
|
|
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
|
|
import 'package:new_budget/src/features/user/application/active_user_controller.dart';
|
|
import 'package:new_budget/src/features/user/domain/entities/user.dart';
|
|
|
|
// ─── Fakes / данные ──────────────────────────────────────────────────────────
|
|
|
|
const _userId = 'test-uid';
|
|
|
|
class FakeActiveUserController extends ActiveUserController {
|
|
@override
|
|
Future<User?> build() async =>
|
|
User(id: _userId, name: 'Test', createdAt: DateTime(2024));
|
|
}
|
|
|
|
Transaction _tx(
|
|
String id,
|
|
int amount, {
|
|
SpendingObligation? obligation,
|
|
SpendingImpulse? impulse,
|
|
}) =>
|
|
Transaction(
|
|
id: id,
|
|
userId: _userId,
|
|
accountId: 'a',
|
|
type: TransactionType.expense,
|
|
amount: amount,
|
|
date: DateTime(2026, 6, 10),
|
|
obligation: obligation,
|
|
impulse: impulse,
|
|
createdAt: DateTime(2026, 6, 10),
|
|
);
|
|
|
|
final _txs = [
|
|
_tx('1', 1000, obligation: SpendingObligation.required),
|
|
_tx('2', 2000,
|
|
obligation: SpendingObligation.unnecessary,
|
|
impulse: SpendingImpulse.impulsive),
|
|
_tx('3', 500,
|
|
obligation: SpendingObligation.optional,
|
|
impulse: SpendingImpulse.considered),
|
|
];
|
|
|
|
Widget _buildScreen() => ProviderScope(
|
|
overrides: [
|
|
activeUserControllerProvider
|
|
.overrideWith(() => FakeActiveUserController()),
|
|
habitMonthTransactionsProvider(_userId).overrideWithValue(_txs),
|
|
categoriesStreamProvider(_userId)
|
|
.overrideWith((ref) => Stream<List<Category>>.value(const [])),
|
|
accountsStreamProvider(_userId)
|
|
.overrideWith((ref) => Stream<List<Account>>.value(const [])),
|
|
],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: const HabitAnalysisScreen(),
|
|
),
|
|
);
|
|
|
|
ProviderContainer _container(WidgetTester tester) =>
|
|
ProviderScope.containerOf(tester.element(find.byType(HabitAnalysisScreen)));
|
|
|
|
/// Сегмент «Impulse» в строке фильтров (первый — пилюли в списке ниже).
|
|
Finder _impulseSegment() => find.text('Impulse').first;
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
void main() {
|
|
setUpAll(() {
|
|
GoogleFonts.config.allowRuntimeFetching = false;
|
|
});
|
|
|
|
testWidgets('по умолчанию: оба «All» активны, чип «Required» виден',
|
|
(tester) async {
|
|
await tester.pumpWidget(_buildScreen());
|
|
await tester.pump(); // activeUserControllerProvider разрешается
|
|
|
|
// «All» в обеих строках фильтров.
|
|
expect(find.text('All'), findsNWidgets(2));
|
|
// Чип обязательности + пилюля tx1 в списке.
|
|
expect(find.text('Required'), findsNWidgets(2));
|
|
});
|
|
|
|
testWidgets('выбор «Impulse» скрывает чип «Required»', (tester) async {
|
|
await tester.pumpWidget(_buildScreen());
|
|
await tester.pump();
|
|
|
|
await tester.tap(_impulseSegment());
|
|
// AnimatedSize — доигрываем анимацию.
|
|
await tester.pumpAndSettle();
|
|
|
|
// Чип скрыт, tx1 отфильтрована — «Required» нет нигде.
|
|
expect(find.text('Required'), findsNothing);
|
|
|
|
// Возврат на «All» импульсивности возвращает чип.
|
|
await tester.tap(find.text('All').first);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Required'), findsNWidgets(2));
|
|
});
|
|
|
|
testWidgets('выбор «Impulse» сбрасывает выбранную «Required» (каскад)',
|
|
(tester) async {
|
|
await tester.pumpWidget(_buildScreen());
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Required').first);
|
|
await tester.pumpAndSettle();
|
|
final container = _container(tester);
|
|
expect(
|
|
container.read(habitObligationFilterProvider), SpendingObligation.required);
|
|
|
|
await tester.tap(_impulseSegment());
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(habitObligationFilterProvider), isNull);
|
|
});
|
|
|
|
testWidgets('повторный тап по чипу обязательности снимает фильтр',
|
|
(tester) async {
|
|
await tester.pumpWidget(_buildScreen());
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Unnecessary').first);
|
|
await tester.pumpAndSettle();
|
|
final container = _container(tester);
|
|
expect(container.read(habitObligationFilterProvider),
|
|
SpendingObligation.unnecessary);
|
|
|
|
await tester.tap(find.text('Unnecessary').first);
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(habitObligationFilterProvider), isNull);
|
|
});
|
|
}
|