feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/features/accounts/account_create_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _host({Broker? broker, String? sourceId, String? name}) => ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => showAccountCreateDialog(
|
||||
context,
|
||||
broker: broker,
|
||||
sourceId: sourceId,
|
||||
name: name,
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('the dialog starts from what the report says', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(broker: Broker.vtb, sourceId: 'BR-42', name: 'ВТБ BR-42'),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Новый брокерский счёт'), findsOneWidget);
|
||||
expect(find.text('BR-42'), findsOneWidget);
|
||||
expect(find.text('ВТБ BR-42'), findsOneWidget);
|
||||
expect(find.text('ВТБ'), findsOneWidget); // the selected broker
|
||||
});
|
||||
|
||||
testWidgets('name and agreement number are required', (tester) async {
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Создать'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Обязательное поле'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
test('import broker keys map onto creatable brokers', () {
|
||||
expect(brokerFromImportKey('sber'), Broker.sber);
|
||||
expect(brokerFromImportKey('vtb'), Broker.vtb);
|
||||
expect(brokerFromImportKey('csv'), Broker.other);
|
||||
expect(brokerFromImportKey('tinvest'), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+120
-61
@@ -1,32 +1,40 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/api/common_providers.dart';
|
||||
import 'package:fintracker_app/core/cache/cached.dart';
|
||||
import 'package:fintracker_app/core/widgets/service_mark.dart';
|
||||
import 'package:fintracker_app/features/home/providers.dart';
|
||||
import 'package:fintracker_app/features/shell/analytics_tabs.dart';
|
||||
import 'package:fintracker_app/features/shell/app_shell.dart';
|
||||
import 'package:fintracker_app/features/shell/nav_destinations.dart';
|
||||
import 'package:fintracker_app/features/shell/nav_sidebar.dart';
|
||||
import 'package:fintracker_app/features/shell/top_nav.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
DataQualityRow _dataQualityRow(int id) => DataQualityRow(
|
||||
checkName: 'x',
|
||||
computedAt: DateTime(2026, 9, 19),
|
||||
count: 1,
|
||||
detail: 'issue $id',
|
||||
id: id,
|
||||
ref: null,
|
||||
severity: 'warning',
|
||||
);
|
||||
checkName: 'x',
|
||||
computedAt: DateTime(2026, 9, 19),
|
||||
count: 1,
|
||||
detail: 'issue $id',
|
||||
id: id,
|
||||
ref: null,
|
||||
severity: 'warning',
|
||||
);
|
||||
|
||||
void main() {
|
||||
// The sidebar (>=1200) watches meProvider and dataQualityProvider for the profile
|
||||
// footer and the Здоровье counter — every wide-breakpoint pump needs both overridden.
|
||||
Widget wrap(double width, {String location = '/'}) {
|
||||
// The top bar watches meProvider and dataQualityProvider for the profile avatar and the
|
||||
// Здоровье counter — every pump at or above 600 needs both overridden.
|
||||
Widget wrap(
|
||||
double width, {
|
||||
String location = '/',
|
||||
List<DataQualityRow> issues = const [],
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)),
|
||||
dataQualityProvider.overrideWith((ref) async => const Cached(<DataQualityRow>[])),
|
||||
meProvider.overrideWith(
|
||||
(ref) async => UserOut(email: 'ada@example.com', id: 1),
|
||||
),
|
||||
dataQualityProvider.overrideWith((ref) async => Cached(issues)),
|
||||
],
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(size: Size(width, 800)),
|
||||
@@ -37,75 +45,126 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows the grouped sidebar on a wide surface', (tester) async {
|
||||
testWidgets('shows the top bar on a wide surface', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280));
|
||||
await tester.pump();
|
||||
expect(find.byType(NavSidebar), findsOneWidget);
|
||||
expect(find.byType(TopNav), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
for (final label in navGroupLabels.values) {
|
||||
expect(find.text(label), findsOneWidget);
|
||||
for (final label in [
|
||||
'Обзор',
|
||||
'Аналитика',
|
||||
'Портфель',
|
||||
'Счета',
|
||||
'Операции',
|
||||
'Добавить',
|
||||
]) {
|
||||
expect(find.text(label), findsOneWidget, reason: label);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('shows a collapsed NavigationRail on a medium surface', (tester) async {
|
||||
await tester.pumpWidget(wrap(900));
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavSidebar), findsNothing);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
});
|
||||
testWidgets(
|
||||
'keeps the top bar on a medium surface, with «Добавить» as an icon',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(wrap(700));
|
||||
await tester.pump();
|
||||
expect(find.byType(TopNav), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.text('Добавить'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows a bottom NavigationBar on a narrow surface', (tester) async {
|
||||
testWidgets('shows a bottom NavigationBar on a narrow surface', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(wrap(400));
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
expect(find.byType(NavSidebar), findsNothing);
|
||||
expect(find.byType(TopNav), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the resolve screen keeps Импорт selected', (tester) async {
|
||||
await tester.pumpWidget(wrap(900, location: '/instruments/pending'));
|
||||
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||
expect(
|
||||
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
||||
'Импорт',
|
||||
);
|
||||
await tester.pumpWidget(wrap(1280, location: '/instruments/pending'));
|
||||
await tester.pump();
|
||||
final nav = tester.widget<TopNav>(find.byType(TopNav));
|
||||
expect(navDestinations[nav.selectedIndex].label, 'Импорт');
|
||||
// a ledger screen shows its own name in place of «Операции»
|
||||
expect(find.text('Импорт'), findsOneWidget);
|
||||
expect(find.text('Операции'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a nested route keeps its own section selected', (tester) async {
|
||||
await tester.pumpWidget(wrap(900, location: '/portfolio/instrument/311'));
|
||||
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||
expect(
|
||||
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
||||
'Портфель',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the sidebar keeps Импорт selected on the resolve screen', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280, location: '/instruments/pending'));
|
||||
await tester.pumpWidget(wrap(1280, location: '/portfolio/instrument/311'));
|
||||
await tester.pump();
|
||||
final sidebar = tester.widget<NavSidebar>(find.byType(NavSidebar));
|
||||
expect(navDestinations[sidebar.selectedIndex].label, 'Импорт');
|
||||
final nav = tester.widget<TopNav>(find.byType(TopNav));
|
||||
expect(navDestinations[nav.selectedIndex].label, 'Портфель');
|
||||
});
|
||||
|
||||
testWidgets('the sidebar shows the Здоровье issue count', (tester) async {
|
||||
testWidgets(
|
||||
'the analytics screens share one tab strip and highlight Аналитика',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(wrap(1280, location: '/income'));
|
||||
await tester.pump();
|
||||
final nav = tester.widget<TopNav>(find.byType(TopNav));
|
||||
expect(navDestinations[nav.selectedIndex].label, 'Аналитика');
|
||||
expect(find.byType(AnalyticsTabs), findsOneWidget);
|
||||
for (final label in [
|
||||
'Общее',
|
||||
'Дивиденды',
|
||||
'Ребалансировка',
|
||||
'Цели',
|
||||
'Налоги',
|
||||
'Портфели',
|
||||
]) {
|
||||
expect(find.text(label), findsOneWidget, reason: label);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('other sections have no analytics tab strip', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280, location: '/accounts'));
|
||||
await tester.pump();
|
||||
expect(find.byType(AnalyticsTabs), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the top bar shows the Здоровье issue count', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)),
|
||||
dataQualityProvider.overrideWith(
|
||||
(ref) async => Cached([
|
||||
_dataQualityRow(1),
|
||||
_dataQualityRow(2),
|
||||
]),
|
||||
),
|
||||
],
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(size: Size(1280, 800)),
|
||||
child: MaterialApp(home: AppShell(location: '/', child: Container())),
|
||||
),
|
||||
),
|
||||
wrap(1280, issues: [_dataQualityRow(1), _dataQualityRow(2)]),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'on a wide monitor the top bar content lines up with the page column',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(2400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(wrap(2400));
|
||||
await tester.pump();
|
||||
|
||||
// the page column is 1440 wide and centred: it starts at 480 and ends at 1920; the cards
|
||||
// inside have 16 px of padding, and the bar's content must sit on the same edges
|
||||
final logo = tester.getTopLeft(find.byType(ServiceMark));
|
||||
expect(
|
||||
logo.dx,
|
||||
480 + 16,
|
||||
reason: 'the logo starts at the column edge plus the card padding',
|
||||
);
|
||||
expect(tester.getTopRight(find.byType(CircleAvatar)).dx, 1920 - 16);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('on a normal window the top bar keeps the plain 16 px margin', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(wrap(1200));
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.getTopLeft(find.byType(ServiceMark)).dx, 16);
|
||||
expect(tester.getTopRight(find.byType(CircleAvatar)).dx, 1200 - 16);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fintracker_app/core/widgets/asset_icon.dart';
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Widget icon) => tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: Center(child: icon)),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('parseBrandColor reads #RRGGBB and #AARRGGBB, and refuses the rest', () {
|
||||
expect(parseBrandColor('#21A038'), const Color(0xFF21A038));
|
||||
expect(parseBrandColor('80FF0000'), const Color(0x80FF0000));
|
||||
expect(parseBrandColor(null), isNull);
|
||||
expect(parseBrandColor('#12'), isNull);
|
||||
expect(parseBrandColor('#GGGGGG'), isNull);
|
||||
});
|
||||
|
||||
testWidgets('without a logo the class icon shows on the brand colour', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
const AssetIcon(assetClass: 'bond', logoColor: '#21A038'),
|
||||
);
|
||||
|
||||
expect(find.byType(Image), findsNothing);
|
||||
final icon = tester.widget<Icon>(find.byType(Icon));
|
||||
expect(icon.icon, assetClassIcon('bond'));
|
||||
expect(icon.color, Colors.white); // dark green → light glyph
|
||||
final fill = tester.widget<ColoredBox>(find.byType(ColoredBox).last);
|
||||
expect(fill.color, const Color(0xFF21A038));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'with a logo an image is requested, and a failed load falls back',
|
||||
(tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
const AssetIcon(
|
||||
assetClass: 'share',
|
||||
logoUrl: 'http://127.0.0.1:1/none.png',
|
||||
),
|
||||
);
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 200)),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byIcon(assetClassIcon('share')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
test('every asset class has its own icon, unknown ones a neutral one', () {
|
||||
expect(assetClassIcon('share'), isNot(assetClassIcon('bond')));
|
||||
expect(assetClassIcon('something-new'), Icons.circle_outlined);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:fintracker_app/core/glossary.dart';
|
||||
import 'package:fintracker_app/core/widgets/help_tip.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _app(Widget child) => MaterialApp(
|
||||
home: Scaffold(body: Center(child: child)),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('the glossary finds a term regardless of case, spaces and «ё»', () {
|
||||
expect(glossaryHint('XIRR'), isNotNull);
|
||||
expect(glossaryHint(' xirr '), glossaryHint('XIRR'));
|
||||
expect(glossaryHint('Запас хода (runway)'), isNotNull);
|
||||
expect(glossaryHint('Внешний поток'), isNotNull);
|
||||
expect(
|
||||
glossaryHint('Счёт'),
|
||||
isNull,
|
||||
reason: 'an ordinary word is not a term',
|
||||
);
|
||||
});
|
||||
|
||||
test('every explanation is a real sentence, not a stub', () {
|
||||
for (final label in [
|
||||
'XIRR',
|
||||
'TWR',
|
||||
'НКД',
|
||||
'ЛДВ',
|
||||
'Пассивный доход',
|
||||
'Запас хода (runway)',
|
||||
]) {
|
||||
final text = glossaryHint(label)!;
|
||||
expect(text.length, greaterThan(40), reason: label);
|
||||
expect(text.trim().endsWith('.'), isTrue, reason: label);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a known term carries a «?», an unknown label stays plain text', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [TermLabel('TWR'), TermLabel('Название')],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('TWR'), findsOneWidget);
|
||||
expect(find.text('Название'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.help_outline), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an explicit hint wins over the glossary', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(const TermLabel('Прибыль', hint: 'Своё пояснение для налогов.')),
|
||||
);
|
||||
|
||||
final tip = tester.widget<Tooltip>(find.byType(Tooltip));
|
||||
expect(tip.message, 'Своё пояснение для налогов.');
|
||||
});
|
||||
|
||||
testWidgets('hovering the «?» with a mouse shows the explanation', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_app(const TermLabel('XIRR')));
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: Offset.zero);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.moveTo(tester.getCenter(find.byIcon(Icons.help_outline)));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.textContaining('годовая доходность с учётом дат'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a tap shows it too, for a phone with no hover', (tester) async {
|
||||
await tester.pumpWidget(_app(const TermLabel('НКД')));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.help_outline));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.textContaining('накопленный купонный доход'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a numeric header keeps its label at the end of the cell', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
const SizedBox(
|
||||
width: 200,
|
||||
child: TermLabel(
|
||||
'XIRR',
|
||||
alignment: MainAxisAlignment.end,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final label = tester.getRect(find.text('XIRR'));
|
||||
final icon = tester.getRect(find.byIcon(Icons.help_outline));
|
||||
expect(
|
||||
icon.left,
|
||||
greaterThanOrEqualTo(label.right),
|
||||
reason: 'the «?» follows the text',
|
||||
);
|
||||
expect(
|
||||
icon.right,
|
||||
greaterThan(150),
|
||||
reason: 'and the pair hugs the right edge',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -8,41 +8,63 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
NetWorthBreakdown _breakdown() => NetWorthBreakdown(
|
||||
accounts: const [],
|
||||
byCurrency: const {'RUB': '123456.78'},
|
||||
d: DateTime(2026, 9, 17),
|
||||
debtRub: '1000',
|
||||
investmentRub: '2000',
|
||||
liquidRub: '3000',
|
||||
missingFxCount: 0,
|
||||
savingsRub: '4000',
|
||||
totalRub: '123456.78',
|
||||
);
|
||||
accounts: const [],
|
||||
byCurrency: const {'RUB': '123456.78'},
|
||||
d: DateTime(2026, 9, 17),
|
||||
debtRub: '1000',
|
||||
investmentRub: '2000',
|
||||
liquidRub: '3000',
|
||||
missingFxCount: 0,
|
||||
savingsRub: '4000',
|
||||
totalRub: '123456.78',
|
||||
);
|
||||
|
||||
RunwayOut _runway() => RunwayOut(
|
||||
asOf: DateTime(2026, 9, 17),
|
||||
avgBaseline3mRub: '0',
|
||||
liquidReserveRub: '0',
|
||||
runwayMonths: null,
|
||||
);
|
||||
asOf: DateTime(2026, 9, 17),
|
||||
avgBaseline3mRub: '0',
|
||||
liquidReserveRub: '0',
|
||||
runwayMonths: null,
|
||||
);
|
||||
|
||||
// Every provider HomePage watches needs an override — including
|
||||
// portfolioSummaryHomeProvider, easy to forget since it renders nothing on an
|
||||
// empty ledger. Without it, HomePage falls through to the real apiProvider,
|
||||
// which now also opens a real ResponseCacheDatabase (see docs/ai/offline-cache.md).
|
||||
List<Override> _overrides({DateTime? staleAt}) => [
|
||||
netWorthBreakdownProvider.overrideWith((ref) => Cached(_breakdown(), fetchedAt: staleAt)),
|
||||
netWorthSeriesProvider.overrideWith((ref) => Cached(const <NetWorthDay>[], fetchedAt: staleAt)),
|
||||
cashflowThisMonthProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
|
||||
cashflowLast12Provider.overrideWith((ref) => Cached(const <CashFlowMonth>[], fetchedAt: staleAt)),
|
||||
runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)),
|
||||
metricsStatusProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
|
||||
dataQualityProvider.overrideWith((ref) => Cached(const <DataQualityRow>[], fetchedAt: staleAt)),
|
||||
portfolioSummaryHomeProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
|
||||
];
|
||||
netWorthBreakdownProvider.overrideWith(
|
||||
(ref) => Cached(_breakdown(), fetchedAt: staleAt),
|
||||
),
|
||||
netWorthSeriesProvider.overrideWith(
|
||||
(ref) => Cached(const <NetWorthDay>[], fetchedAt: staleAt),
|
||||
),
|
||||
cashflowThisMonthProvider.overrideWith(
|
||||
(ref) => Cached(null, fetchedAt: staleAt),
|
||||
),
|
||||
cashflowLast12Provider.overrideWith(
|
||||
(ref) => Cached(const <CashFlowMonth>[], fetchedAt: staleAt),
|
||||
),
|
||||
runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)),
|
||||
metricsStatusProvider.overrideWith(
|
||||
(ref) => Cached(
|
||||
MetricsStatusOut(lastRefresh: null, consistent: true, refreshing: false),
|
||||
fetchedAt: staleAt,
|
||||
),
|
||||
),
|
||||
dataQualityProvider.overrideWith(
|
||||
(ref) => Cached(const <DataQualityRow>[], fetchedAt: staleAt),
|
||||
),
|
||||
portfolioSummaryHomeProvider.overrideWith(
|
||||
(ref) => Cached(null, fetchedAt: staleAt),
|
||||
),
|
||||
scopeCardsProvider.overrideWith(
|
||||
(ref) => Cached(const <ScopeCardOut>[], fetchedAt: staleAt),
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
testWidgets('Обзор renders stat tiles from a net worth breakdown', (tester) async {
|
||||
testWidgets('Обзор renders stat tiles from a net worth breakdown', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: _overrides(),
|
||||
@@ -60,15 +82,21 @@ void main() {
|
||||
expect(find.textContaining('Нет соединения'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows the offline banner when the dashboard came from the cache', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)),
|
||||
child: const MaterialApp(home: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets(
|
||||
'shows the offline banner when the dashboard came from the cache',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)),
|
||||
child: const MaterialApp(home: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Нет соединения — данные на 10.09.2026 08:30'), findsOneWidget);
|
||||
});
|
||||
expect(
|
||||
find.textContaining('Нет соединения — данные на 10.09.2026 08:30'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/features/portfolio/instrument_edit_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
InstrumentOut _instrument() => InstrumentOut(
|
||||
id: 1,
|
||||
assetClass: 'share',
|
||||
isin: 'RU0008958863',
|
||||
figi: null,
|
||||
ticker: 'MSNG',
|
||||
board: 'TQBR',
|
||||
exchange: null,
|
||||
name: 'Мосэнерго',
|
||||
issuer: null,
|
||||
currency: 'RUB',
|
||||
lot: 1,
|
||||
nominal: null,
|
||||
nominalCurrency: null,
|
||||
maturityDate: null,
|
||||
sector: null,
|
||||
country: null,
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
Future<InstrumentPatch?> _open(
|
||||
WidgetTester tester,
|
||||
Future<void> Function() interact,
|
||||
) async {
|
||||
InstrumentPatch? result;
|
||||
var closed = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () async {
|
||||
result = await showDialog<InstrumentPatch>(
|
||||
context: context,
|
||||
builder: (_) => InstrumentEditDialog(instrument: _instrument()),
|
||||
);
|
||||
closed = true;
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
await interact();
|
||||
await tester.pumpAndSettle();
|
||||
expect(closed, isTrue);
|
||||
return result;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('only the changed lot goes into the patch', (tester) async {
|
||||
final patch = await _open(tester, () async {
|
||||
await tester.enterText(find.widgetWithText(TextFormField, '1'), '1000');
|
||||
await tester.tap(find.text('Сохранить'));
|
||||
});
|
||||
|
||||
expect(patch, isNotNull);
|
||||
expect(patch!.lot, 1000);
|
||||
expect(patch.name, isNull);
|
||||
expect(patch.board, isNull);
|
||||
expect(patch.assetClass, isNull);
|
||||
});
|
||||
|
||||
testWidgets('saving without changes closes with nothing to send', (
|
||||
tester,
|
||||
) async {
|
||||
final patch = await _open(tester, () async {
|
||||
await tester.tap(find.text('Сохранить'));
|
||||
});
|
||||
|
||||
expect(patch, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a lot below 1 is refused and the dialog stays open', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => showDialog<InstrumentPatch>(
|
||||
context: context,
|
||||
builder: (_) => InstrumentEditDialog(instrument: _instrument()),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextFormField, '1'), '0');
|
||||
await tester.tap(find.text('Сохранить'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Целое число не меньше 1'), findsOneWidget);
|
||||
expect(find.text('Сохранить'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/cache/cached.dart';
|
||||
import 'package:fintracker_app/features/accounts/providers.dart';
|
||||
import 'package:fintracker_app/features/events/manual_event_dialog.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, {
|
||||
bool disabled = false,
|
||||
}) => AccountOut(
|
||||
id: id,
|
||||
kind: kind,
|
||||
source_: 'test',
|
||||
sourceId: 'acc-$id',
|
||||
broker: null,
|
||||
name: name,
|
||||
currency: 'RUB',
|
||||
role: AccountRole.investment,
|
||||
includeInNetWorth: true,
|
||||
mirrorOfAccountId: null,
|
||||
primaryEventSource: null,
|
||||
archived: false,
|
||||
disabled: disabled,
|
||||
openedAt: null,
|
||||
balance: null,
|
||||
balanceAsOf: null,
|
||||
startBalance: null,
|
||||
creditLimit: null,
|
||||
);
|
||||
|
||||
Future<void> _open(WidgetTester tester, List<AccountOut> accounts) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
accountsProvider.overrideWith((ref) async => Cached(accounts)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => showManualEventDialog(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a purchase asks for account, instrument, quantity and price', (
|
||||
tester,
|
||||
) async {
|
||||
await _open(tester, [_account(1, 'Брокер', AccountKind.broker)]);
|
||||
|
||||
expect(find.text('Новое событие'), findsOneWidget);
|
||||
expect(find.text('Инструмент'), findsOneWidget);
|
||||
expect(find.text('Количество, шт.'), findsOneWidget);
|
||||
expect(find.text('Цена за штуку'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Добавить'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Выберите счёт'), findsOneWidget);
|
||||
expect(find.text('Выберите инструмент из списка'), findsOneWidget);
|
||||
expect(find.text('Обязательное поле'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('only active broker accounts can be picked', (tester) async {
|
||||
await _open(tester, [
|
||||
_account(1, 'Брокер', AccountKind.broker),
|
||||
_account(2, 'Отключённый', AccountKind.broker, disabled: true),
|
||||
_account(3, 'Карта', AccountKind.zmCard),
|
||||
]);
|
||||
|
||||
await tester.tap(find.text('Брокерский счёт'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Брокер'), findsOneWidget);
|
||||
expect(find.text('Отключённый'), findsNothing);
|
||||
expect(find.text('Карта'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a deposit needs only an amount: no instrument, quantity or price',
|
||||
(tester) async {
|
||||
await _open(tester, [_account(1, 'Брокер', AccountKind.broker)]);
|
||||
|
||||
await tester.tap(find.text('Покупка'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Пополнение').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Инструмент'), findsNothing);
|
||||
expect(find.text('Количество, шт.'), findsNothing);
|
||||
expect(find.text('Цена за штуку'), findsNothing);
|
||||
expect(find.text('Сумма'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/features/home/providers.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
MetricsStatusOut _status({required bool refreshing, bool consistent = true}) =>
|
||||
MetricsStatusOut(
|
||||
lastRefresh: null,
|
||||
consistent: consistent,
|
||||
refreshing: refreshing,
|
||||
);
|
||||
|
||||
void main() {
|
||||
const fast = Duration(milliseconds: 1);
|
||||
|
||||
test('polls until the server stops reporting a rebuild', () async {
|
||||
final answers = [
|
||||
_status(refreshing: true),
|
||||
_status(refreshing: true),
|
||||
_status(refreshing: false),
|
||||
];
|
||||
var calls = 0;
|
||||
|
||||
final done = await waitForMetricsRefresh(
|
||||
() async => answers[calls++],
|
||||
interval: fast,
|
||||
);
|
||||
|
||||
expect(calls, 3);
|
||||
expect(done?.refreshing, isFalse);
|
||||
});
|
||||
|
||||
test('returns straight away when nothing is queued', () async {
|
||||
var calls = 0;
|
||||
final done = await waitForMetricsRefresh(() async {
|
||||
calls++;
|
||||
return _status(refreshing: false);
|
||||
}, interval: fast);
|
||||
|
||||
expect(calls, 1);
|
||||
expect(done, isNotNull);
|
||||
});
|
||||
|
||||
test('a failed rebuild is handed back so the caller can say so', () async {
|
||||
final done = await waitForMetricsRefresh(
|
||||
() async => _status(refreshing: false, consistent: false),
|
||||
interval: fast,
|
||||
);
|
||||
|
||||
expect(done?.consistent, isFalse);
|
||||
});
|
||||
|
||||
test('gives up with null when the rebuild never finishes', () async {
|
||||
final done = await waitForMetricsRefresh(
|
||||
() async => _status(refreshing: true),
|
||||
interval: fast,
|
||||
timeout: const Duration(milliseconds: 20),
|
||||
);
|
||||
|
||||
expect(done, isNull);
|
||||
});
|
||||
}
|
||||
@@ -49,26 +49,26 @@ HoldingOut holding({
|
||||
}
|
||||
|
||||
SummaryOut summary() => SummaryOut(
|
||||
asOf: DateTime(2026, 9, 18),
|
||||
cashRub: '5700',
|
||||
computedAt: DateTime.utc(2026, 9, 18, 10),
|
||||
holdingCount: 2,
|
||||
incomeRub: '700',
|
||||
investedNetRub: '20000',
|
||||
marketValueRub: '11000',
|
||||
pnlTotalRub: null,
|
||||
realizedPnlRub: '0',
|
||||
returns: const [],
|
||||
scope: 'all',
|
||||
staleCount: 0,
|
||||
totalRub: '16700',
|
||||
unpricedCount: 1,
|
||||
);
|
||||
asOf: DateTime(2026, 9, 18),
|
||||
cashRub: '5700',
|
||||
computedAt: DateTime.utc(2026, 9, 18, 10),
|
||||
holdingCount: 2,
|
||||
incomeRub: '700',
|
||||
investedNetRub: '20000',
|
||||
marketValueRub: '11000',
|
||||
pnlTotalRub: null,
|
||||
realizedPnlRub: '0',
|
||||
returns: const [],
|
||||
scope: 'all',
|
||||
staleCount: 0,
|
||||
totalRub: '16700',
|
||||
unpricedCount: 1,
|
||||
);
|
||||
|
||||
Widget _wrap(Widget child, List<Override> overrides) => ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(home: Scaffold(body: child)),
|
||||
);
|
||||
overrides: overrides,
|
||||
child: MaterialApp(home: Scaffold(body: child)),
|
||||
);
|
||||
|
||||
/// The tabs are long scrolling lists and a ListView only builds what fits, so the default
|
||||
/// 800x600 surface would leave the holdings table unbuilt and every `find` on it empty.
|
||||
@@ -81,22 +81,30 @@ Future<void> pumpTall(WidgetTester tester, Widget widget) async {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (tester) async {
|
||||
await pumpTall(tester, _wrap(
|
||||
const HoldingsTab(),
|
||||
[
|
||||
testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTall(
|
||||
tester,
|
||||
_wrap(const HoldingsTab(), [
|
||||
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
|
||||
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
|
||||
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])),
|
||||
holdingsProvider.overrideWith((ref) => Cached([
|
||||
holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'),
|
||||
holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'),
|
||||
])),
|
||||
portfolioReturnsProvider.overrideWith(
|
||||
(ref) => const Cached(<ReturnsOut>[]),
|
||||
),
|
||||
holdingsProvider.overrideWith(
|
||||
(ref) => Cached([
|
||||
holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'),
|
||||
holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'),
|
||||
]),
|
||||
),
|
||||
// HoldingsTab embeds BenchmarksCard, which watches this too — leaving it out would
|
||||
// fall through to the real apiProvider (see docs/ai/offline-cache.md).
|
||||
benchmarkRowsProvider.overrideWith((ref) => const Cached(<BenchmarkRow>[])),
|
||||
],
|
||||
));
|
||||
benchmarkRowsProvider.overrideWith(
|
||||
(ref) => const Cached(<BenchmarkRow>[]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(find.text('GAZP'), findsOneWidget);
|
||||
expect(find.text('SIBN6P4'), findsOneWidget);
|
||||
@@ -107,44 +115,54 @@ void main() {
|
||||
expect(find.text(MoneyText.format('0', 'RUB')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Позиции show the total profit as «—» while a price is missing', (tester) async {
|
||||
await pumpTall(tester, _wrap(
|
||||
const HoldingsTab(),
|
||||
[
|
||||
testWidgets('Позиции show the total profit as «—» while a price is missing', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTall(
|
||||
tester,
|
||||
_wrap(const HoldingsTab(), [
|
||||
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
|
||||
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
|
||||
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])),
|
||||
portfolioReturnsProvider.overrideWith(
|
||||
(ref) => const Cached(<ReturnsOut>[]),
|
||||
),
|
||||
holdingsProvider.overrideWith((ref) => const Cached(<HoldingOut>[])),
|
||||
benchmarkRowsProvider.overrideWith((ref) => const Cached(<BenchmarkRow>[])),
|
||||
],
|
||||
));
|
||||
benchmarkRowsProvider.overrideWith(
|
||||
(ref) => const Cached(<BenchmarkRow>[]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(find.text('Прибыль'), findsOneWidget);
|
||||
expect(find.text('часть позиций без цены'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Аллокация labels the cash and unknown buckets in Russian', (tester) async {
|
||||
await pumpTall(tester, _wrap(
|
||||
const AllocationTab(),
|
||||
[
|
||||
allocationProvider.overrideWith((ref) => Cached([
|
||||
AllocationBucket(
|
||||
bucket: 'share',
|
||||
dimension: AllocationDimension.assetClass,
|
||||
holdingCount: 1,
|
||||
valueRub: '11000',
|
||||
weight: '0.6587',
|
||||
),
|
||||
AllocationBucket(
|
||||
bucket: 'cash',
|
||||
dimension: AllocationDimension.assetClass,
|
||||
holdingCount: 0,
|
||||
valueRub: '5700',
|
||||
weight: '0.3413',
|
||||
),
|
||||
])),
|
||||
],
|
||||
));
|
||||
testWidgets('Аллокация labels the cash and unknown buckets in Russian', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTall(
|
||||
tester,
|
||||
_wrap(const AllocationTab(), [
|
||||
allocationProvider.overrideWith(
|
||||
(ref) => Cached([
|
||||
AllocationBucket(
|
||||
bucket: 'share',
|
||||
dimension: AllocationDimension.assetClass,
|
||||
holdingCount: 1,
|
||||
valueRub: '11000',
|
||||
weight: '0.6587',
|
||||
),
|
||||
AllocationBucket(
|
||||
bucket: 'cash',
|
||||
dimension: AllocationDimension.assetClass,
|
||||
holdingCount: 0,
|
||||
valueRub: '5700',
|
||||
weight: '0.3413',
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(find.text('Класс актива'), findsOneWidget);
|
||||
expect(find.text('Акции'), findsOneWidget);
|
||||
@@ -168,6 +186,9 @@ void main() {
|
||||
test('bucketLabel falls back to the source value it does not know', () {
|
||||
expect(bucketLabel(AllocationDimension.country, 'RU'), 'Россия');
|
||||
expect(bucketLabel(AllocationDimension.country, 'unknown'), 'Не указано');
|
||||
expect(bucketLabel(AllocationDimension.sector, 'oil_and_gas'), 'oil_and_gas');
|
||||
expect(
|
||||
bucketLabel(AllocationDimension.sector, 'oil_and_gas'),
|
||||
'oil_and_gas',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/cache/cached.dart';
|
||||
import 'package:fintracker_app/features/accounts/providers.dart';
|
||||
import 'package:fintracker_app/features/portfolios/portfolios_page.dart';
|
||||
import 'package:fintracker_app/features/portfolios/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) => AccountOut(
|
||||
id: id,
|
||||
kind: kind,
|
||||
source_: 'test',
|
||||
sourceId: 'acc-$id',
|
||||
broker: null,
|
||||
name: name,
|
||||
currency: 'RUB',
|
||||
role: AccountRole.investment,
|
||||
includeInNetWorth: true,
|
||||
mirrorOfAccountId: null,
|
||||
primaryEventSource: null,
|
||||
archived: false,
|
||||
disabled: false,
|
||||
openedAt: null,
|
||||
balance: null,
|
||||
balanceAsOf: null,
|
||||
startBalance: null,
|
||||
creditLimit: null,
|
||||
);
|
||||
|
||||
Widget _app(List<PortfolioOut> portfolios, List<AccountOut> accounts) =>
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
portfolioListProvider.overrideWith((ref) async => portfolios),
|
||||
accountsProvider.overrideWith((ref) async => Cached(accounts)),
|
||||
],
|
||||
child: const MaterialApp(home: PortfoliosPage()),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('an empty list explains what a portfolio is', (tester) async {
|
||||
await tester.pumpWidget(_app(const [], const []));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Портфелей пока нет'), findsOneWidget);
|
||||
expect(find.text('Новый портфель'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a portfolio shows its accounts by name', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
[
|
||||
PortfolioOut(
|
||||
id: 1,
|
||||
name: 'Основной',
|
||||
baseCurrency: 'RUB',
|
||||
accountIds: [10, 11],
|
||||
),
|
||||
],
|
||||
[
|
||||
_account(10, 'Брокер А', AccountKind.broker),
|
||||
_account(11, 'Брокер Б', AccountKind.broker),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Основной'), findsOneWidget);
|
||||
expect(find.text('2 счёта: Брокер А, Брокер Б'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the new-portfolio dialog needs a name and lists brokers first', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(const [], [
|
||||
_account(1, 'Карта', AccountKind.zmCard),
|
||||
_account(2, 'Брокер', AccountKind.broker),
|
||||
]),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Новый портфель'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titles = tester
|
||||
.widgetList<CheckboxListTile>(find.byType(CheckboxListTile))
|
||||
.toList();
|
||||
expect((titles.first.title as Text).data, 'Брокер');
|
||||
|
||||
await tester.tap(find.text('Сохранить'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Обязательное поле'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||||
import 'package:fintracker_app/features/home/scope_cards.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
ScopeCardOut _card({
|
||||
String scope = 'portfolio:1',
|
||||
String name = 'ИИСы',
|
||||
String kind = 'portfolio',
|
||||
String total = '224608.65',
|
||||
String? pnl = '6643.47',
|
||||
String? pnlPct = '0.015',
|
||||
String? day = '-265.68',
|
||||
String? dayPct = '-0.001',
|
||||
String? xirr = '0.0298',
|
||||
String income = '17387.49',
|
||||
String? incomePct = '0.043',
|
||||
}) => ScopeCardOut(
|
||||
scope: scope,
|
||||
name: name,
|
||||
kind: kind,
|
||||
asOf: DateTime(2026, 9, 19),
|
||||
totalRub: total,
|
||||
investedRub: '200000',
|
||||
pnlRub: pnl,
|
||||
pnlPct: pnlPct,
|
||||
dayChangeRub: day,
|
||||
dayChangePct: dayPct,
|
||||
xirr: xirr,
|
||||
incomeYearRub: income,
|
||||
incomeYearPct: incomePct,
|
||||
);
|
||||
|
||||
Widget _app(List<ScopeCardOut> cards, {double width = 1200}) => ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(size: Size(width, 900)),
|
||||
child: Scaffold(
|
||||
body: SingleChildScrollView(child: ScopeCards(cards: cards)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('a card shows the name, the value and the four figures', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_app([_card()]));
|
||||
|
||||
expect(find.text('ИИСЫ'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('224608.65', 'RUB')), findsOneWidget);
|
||||
for (final label in [
|
||||
'Прибыль',
|
||||
'За день',
|
||||
'Доходность',
|
||||
'Пассивный доход',
|
||||
]) {
|
||||
expect(find.text(label), findsOneWidget, reason: label);
|
||||
}
|
||||
// the gain carries an explicit plus, the loss its own minus
|
||||
expect(find.text('+${MoneyText.format('6643.47', 'RUB')}'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('-265.68', 'RUB')), findsOneWidget);
|
||||
expect(find.text('2,98 %'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining(MoneyText.format('17387.49', 'RUB')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('missing figures read as a dash, not as zero', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app([
|
||||
_card(
|
||||
pnl: null,
|
||||
pnlPct: null,
|
||||
day: null,
|
||||
dayPct: null,
|
||||
xirr: null,
|
||||
income: '0',
|
||||
incomePct: null,
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(find.text('—'), findsNWidgets(4));
|
||||
});
|
||||
|
||||
testWidgets('cards flow into columns by the available width', (tester) async {
|
||||
final cards = [
|
||||
for (var i = 0; i < 6; i++) _card(scope: 'account:$i', name: 'Счёт $i'),
|
||||
];
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
Future<int> columns(double width) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
await tester.pumpWidget(_app(cards, width: width));
|
||||
final xs = {
|
||||
for (final c in cards)
|
||||
tester.getTopLeft(find.text('СЧЁТ ${cards.indexOf(c)}')).dx.round(),
|
||||
};
|
||||
return xs.length;
|
||||
}
|
||||
|
||||
expect(await columns(1200), 3);
|
||||
expect(await columns(800), 2);
|
||||
expect(await columns(400), 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:fintracker_app/core/theme/app_theme.dart';
|
||||
import 'package:fintracker_app/core/widgets/service_mark.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<ByteData> _render(WidgetTester tester, double size) async {
|
||||
final key = GlobalKey();
|
||||
tester.view.physicalSize = Size(size, size);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: RepaintBoundary(
|
||||
key: key,
|
||||
child: ServiceMark(size: size),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final boundary =
|
||||
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
return (await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
return image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
}))!;
|
||||
}
|
||||
|
||||
Color _at(ByteData data, int size, int x, int y) {
|
||||
final i = (y * size + x) * 4;
|
||||
return Color.fromARGB(
|
||||
data.getUint8(i + 3),
|
||||
data.getUint8(i),
|
||||
data.getUint8(i + 1),
|
||||
data.getUint8(i + 2),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'a gradient disc with three white bars, and nothing outside the disc',
|
||||
(tester) async {
|
||||
const size = 256;
|
||||
final data = await _render(tester, size.toDouble());
|
||||
|
||||
// scale of the 512 design grid
|
||||
int px(num v) => (v * size / 512).round();
|
||||
|
||||
expect(
|
||||
_at(data, size, 2, 2).a,
|
||||
0,
|
||||
reason: 'the corner is outside the disc',
|
||||
);
|
||||
// the tallest bar (x 316..380, y 131..381): white in its middle
|
||||
expect(_at(data, size, px(348), px(256)), const Color(0xFFFFFFFF));
|
||||
// between two bars the disc shows through, and it is coloured, not white
|
||||
final gap = _at(data, size, px(210), px(300));
|
||||
expect(gap.a, 1);
|
||||
expect(gap.b, greaterThan(gap.r), reason: 'blue-violet, not grey');
|
||||
// the gradient runs from the accent (top left) toward violet (bottom right)
|
||||
final start = _at(data, size, px(90), px(90));
|
||||
final end = _at(data, size, px(430), px(430));
|
||||
expect(end.r, greaterThan(start.r));
|
||||
expect(start.b * 255, greaterThan(200));
|
||||
expect(
|
||||
(start.g - end.g) * 255,
|
||||
greaterThan(40),
|
||||
reason: 'the green channel falls off toward violet',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('the near end of the gradient is the app accent', () {
|
||||
expect(AppTheme.seed, const Color(0xFF14AFFF));
|
||||
expect(ServiceMark.violet, isNot(AppTheme.seed));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user