Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
259 lines
8.0 KiB
Dart
259 lines
8.0 KiB
Dart
import 'package:fintracker_api/fintracker_api.dart';
|
||
import 'package:fintracker_app/core/cache/cached.dart';
|
||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||
import 'package:fintracker_app/features/accounts/accounts_page.dart';
|
||
import 'package:fintracker_app/features/accounts/actions.dart';
|
||
import 'package:fintracker_app/features/accounts/providers.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
AccountOut _account(
|
||
int id,
|
||
String name,
|
||
AccountKind kind, {
|
||
String? balance,
|
||
String? valueRub,
|
||
bool disabled = false,
|
||
bool archived = false,
|
||
AccountRole? role,
|
||
String source = 'zenmoney',
|
||
}) => AccountOut(
|
||
id: id,
|
||
kind: kind,
|
||
source_: source,
|
||
sourceId: 'acc-$id',
|
||
broker: null,
|
||
name: name,
|
||
currency: 'RUB',
|
||
role:
|
||
role ??
|
||
(kind == AccountKind.broker
|
||
? AccountRole.investment
|
||
: AccountRole.liquid),
|
||
includeInNetWorth: true,
|
||
mirrorOfAccountId: null,
|
||
primaryEventSource: null,
|
||
archived: archived,
|
||
disabled: disabled,
|
||
openedAt: null,
|
||
balance: balance,
|
||
balanceAsOf: null,
|
||
startBalance: null,
|
||
creditLimit: null,
|
||
valueRub: valueRub,
|
||
);
|
||
|
||
class _FakeActions implements AccountActions {
|
||
final calls = <(List<int>, AccountPatch)>[];
|
||
|
||
@override
|
||
Future<String?> patch(Iterable<int> ids, AccountPatch patch) async {
|
||
calls.add((ids.toList(), patch));
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Rows sort as: active by type then name, then disabled — so «Карта» (1), «Брокер» (3),
|
||
/// «Старая карта» (2, disabled).
|
||
List<AccountOut> _sample() => [
|
||
_account(1, 'Карта', AccountKind.zmCard, balance: '500'),
|
||
_account(
|
||
2,
|
||
'Старая карта',
|
||
AccountKind.zmCard,
|
||
balance: '20',
|
||
disabled: true,
|
||
),
|
||
_account(
|
||
3,
|
||
'Брокер',
|
||
AccountKind.broker,
|
||
valueRub: '1044264',
|
||
source: 'tinvest',
|
||
),
|
||
];
|
||
|
||
Future<_FakeActions> _open(
|
||
WidgetTester tester,
|
||
List<AccountOut> accounts, {
|
||
double width = 1200,
|
||
}) async {
|
||
tester.view.physicalSize = Size(width, 1000);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
final fake = _FakeActions();
|
||
await tester.pumpWidget(
|
||
ProviderScope(
|
||
overrides: [
|
||
accountsProvider.overrideWith((ref) async => Cached(accounts)),
|
||
accountActionsProvider.overrideWithValue(fake),
|
||
],
|
||
child: const MaterialApp(home: AccountsPage()),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
return fake;
|
||
}
|
||
|
||
void main() {
|
||
testWidgets(
|
||
'a broker account shows its ledger valuation, a card its balance',
|
||
(tester) async {
|
||
await _open(tester, _sample());
|
||
|
||
expect(find.text(MoneyText.format('1044264', 'RUB')), findsOneWidget);
|
||
expect(find.text(MoneyText.format('500', 'RUB')), findsOneWidget);
|
||
// the source tells a ZenMoney account from a broker one
|
||
expect(find.textContaining('T-Invest'), findsOneWidget);
|
||
expect(find.textContaining('ZenMoney'), findsWidgets);
|
||
},
|
||
);
|
||
|
||
testWidgets('no amount at all reads as a dash, not as zero', (tester) async {
|
||
await _open(tester, [_account(1, 'Пустой брокер', AccountKind.broker)]);
|
||
|
||
expect(find.text('—'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('the status chips count the accounts and filter the list', (
|
||
tester,
|
||
) async {
|
||
await _open(tester, _sample());
|
||
|
||
expect(find.text('Все 3'), findsOneWidget);
|
||
expect(find.text('Активные 2'), findsOneWidget);
|
||
expect(find.text('Отключённые 1'), findsOneWidget);
|
||
expect(find.text('Архивные 0'), findsOneWidget);
|
||
expect(
|
||
find.text('Старая карта'),
|
||
findsOneWidget,
|
||
reason: 'all shows the disabled one too',
|
||
);
|
||
|
||
await tester.tap(find.text('Отключённые 1'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Старая карта'), findsOneWidget);
|
||
expect(find.text('Карта'), findsNothing);
|
||
expect(find.text('Брокер'), findsNothing);
|
||
});
|
||
|
||
testWidgets('the type chips and the search narrow the list', (tester) async {
|
||
await _open(tester, _sample());
|
||
|
||
await tester.tap(find.widgetWithText(FilterChip, 'Инвестиции'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Брокер'), findsOneWidget);
|
||
expect(find.text('Карта'), findsNothing);
|
||
|
||
await tester.tap(find.widgetWithText(FilterChip, 'Инвестиции'));
|
||
await tester.enterText(find.byType(TextField), 'старая');
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Старая карта'), findsOneWidget);
|
||
expect(find.text('Брокер'), findsNothing);
|
||
|
||
await tester.enterText(find.byType(TextField), 'nothing like it');
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Ничего не найдено'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets(
|
||
'the «Активен» switch on a row sends one patch for that account',
|
||
(tester) async {
|
||
final fake = await _open(tester, _sample());
|
||
|
||
// per row the switches are «В капитал» then «Активен»; the first row is «Карта» (id 1)
|
||
await tester.tap(find.byType(Switch).at(1));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(fake.calls, hasLength(1));
|
||
expect(fake.calls.single.$1, [1]);
|
||
expect(fake.calls.single.$2.disabled, isTrue);
|
||
},
|
||
);
|
||
|
||
testWidgets('switching a disabled account back on sends disabled: false', (
|
||
tester,
|
||
) async {
|
||
final fake = await _open(tester, _sample());
|
||
|
||
await tester.tap(find.byType(Switch).at(5)); // «Старая карта», «Активен»
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(fake.calls.single.$1, [2]);
|
||
expect(fake.calls.single.$2.disabled, isFalse);
|
||
});
|
||
|
||
testWidgets('the type pill offers the four types and sends the chosen one', (
|
||
tester,
|
||
) async {
|
||
final fake = await _open(tester, _sample());
|
||
|
||
await tester.tap(find.byTooltip('Тип счёта').first);
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(
|
||
find.widgetWithText(CheckedPopupMenuItem<AccountRole>, 'Сбережения'),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(fake.calls.single.$1, [1]);
|
||
expect(fake.calls.single.$2.role, AccountRole.savings);
|
||
});
|
||
|
||
testWidgets(
|
||
'a selection turns into bulk actions and clears itself afterwards',
|
||
(tester) async {
|
||
final fake = await _open(tester, _sample());
|
||
|
||
// checkbox 0 is «select all»; rows follow in list order: Карта (1), Брокер (3)
|
||
await tester.tap(find.byType(Checkbox).at(1));
|
||
await tester.tap(find.byType(Checkbox).at(2));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Выбрано: 2'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Отключить'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(fake.calls.single.$1, unorderedEquals([1, 3]));
|
||
expect(fake.calls.single.$2.disabled, isTrue);
|
||
expect(
|
||
find.textContaining('Выбрано'),
|
||
findsNothing,
|
||
reason: 'the selection is spent',
|
||
);
|
||
},
|
||
);
|
||
|
||
testWidgets('«select all» takes exactly what the filter shows', (
|
||
tester,
|
||
) async {
|
||
final fake = await _open(tester, _sample());
|
||
|
||
await tester.tap(find.text('Отключённые 1'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.byType(Checkbox).first);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Выбрано: 1'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Включить'));
|
||
await tester.pumpAndSettle();
|
||
expect(fake.calls.single.$1, [2]);
|
||
expect(fake.calls.single.$2.disabled, isFalse);
|
||
});
|
||
|
||
testWidgets(
|
||
'a narrow window puts the controls on a second line with their labels',
|
||
(tester) async {
|
||
await _open(tester, _sample(), width: 500);
|
||
|
||
expect(find.text('Активен'), findsNWidgets(3));
|
||
expect(find.text('В капитал'), findsNWidgets(3));
|
||
expect(
|
||
find.text('Баланс / стоимость'),
|
||
findsNothing,
|
||
reason: 'no table header on a phone',
|
||
);
|
||
},
|
||
);
|
||
}
|