feat(app): экраны портфеля — позиции, аллокация и карточка инструмента

Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации:
они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы
по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх
экранов — три экрана с разными scope были бы ловушкой.

Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в
итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для
общей прибыли, когда в портфеле есть хоть одна неоценённая бумага.

Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя
(assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает
никогда.

Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView
строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
This commit is contained in:
Dmitry
2026-09-18 14:21:06 +03:00
parent 28ff63bdfa
commit 1d7769ffbf
11 changed files with 1530 additions and 2 deletions
+166
View File
@@ -0,0 +1,166 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/widgets/money_text.dart';
import 'package:fintracker_app/features/portfolio/allocation_tab.dart';
import 'package:fintracker_app/features/portfolio/holdings_tab.dart';
import 'package:fintracker_app/features/portfolio/labels.dart';
import 'package:fintracker_app/features/portfolio/providers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
HoldingOut holding({
required int id,
required String ticker,
String? valueRub,
String priceStatus = 'ok',
String? weight,
}) {
return HoldingOut(
accruedInterestRub: null,
assetClass: 'share',
avgCost: '100',
board: 'TQBR',
costCurrency: 'RUB',
costTotalRub: '10000',
currency: 'RUB',
daysHeld: 40,
firstBuyDate: DateTime(2026, 8, 1),
incomeRub: '0',
instrumentId: id,
ldvEligibleQty: '0',
marketPrice: priceStatus == 'missing' ? null : '110',
name: ticker,
priceCurrency: 'RUB',
priceDate: DateTime(2026, 9, 17),
priceStatus: priceStatus,
qty: '100',
realizedPnlRub: '0',
ticker: ticker,
unrealizedPnlNative: valueRub == null ? null : '1000',
unrealizedPnlRub: valueRub == null ? null : '1000',
valueNative: valueRub,
valueRub: valueRub,
weight: weight,
xirr: null,
);
}
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,
);
Widget _wrap(Widget child, List<Override> overrides) => ProviderScope(
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.
Future<void> pumpTall(WidgetTester tester, Widget widget) async {
tester.view.physicalSize = const Size(1400, 3000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(widget);
await tester.pumpAndSettle();
}
void main() {
testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (tester) async {
await pumpTall(tester, _wrap(
const HoldingsTab(),
[
portfolioSummaryProvider.overrideWith((ref) => summary()),
valueSeriesProvider.overrideWith((ref) => const <ValueDay>[]),
portfolioReturnsProvider.overrideWith((ref) => const <ReturnsOut>[]),
holdingsProvider.overrideWith((ref) => [
holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'),
holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'),
]),
],
));
expect(find.text('GAZP'), findsOneWidget);
expect(find.text('SIBN6P4'), findsOneWidget);
expect(find.text(MoneyText.format('11000', 'RUB')), findsWidgets);
// the unpriced row is dashes all the way across — price, value, weight, P&L, XIRR —
// and nowhere on the screen is an unknown number rendered as 0 ₽
expect(find.text(''), findsAtLeastNWidgets(5));
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(),
[
portfolioSummaryProvider.overrideWith((ref) => summary()),
valueSeriesProvider.overrideWith((ref) => const <ValueDay>[]),
portfolioReturnsProvider.overrideWith((ref) => const <ReturnsOut>[]),
holdingsProvider.overrideWith((ref) => const <HoldingOut>[]),
],
));
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) => [
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);
expect(find.text('Денежные средства'), findsOneWidget);
expect(find.text('65,87 %'), findsOneWidget);
});
test('formatPercent keeps null distinct from zero', () {
expect(formatPercent(null), '');
expect(formatPercent('0'), '0,00 %');
expect(formatPercent('0.1401'), '+14,01 %');
expect(formatPercent('-0.128'), '-12,80 %');
expect(formatPercent('0.65', signed: false), '65,00 %');
});
test('formatQty trims the NUMERIC(24,10) tail', () {
expect(formatQty('215.0000000000'), '215');
expect(formatQty('12.5000000000'), '12,5');
});
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');
});
}