Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
195 lines
6.5 KiB
Dart
195 lines
6.5 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/portfolio/allocation_tab.dart';
|
|
import 'package:fintracker_app/features/portfolio/benchmarks_card.dart';
|
|
import 'package:fintracker_app/features/portfolio/data/benchmarks_api.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) => 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'),
|
|
]),
|
|
),
|
|
// 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>[]),
|
|
),
|
|
]),
|
|
);
|
|
|
|
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) => Cached(summary())),
|
|
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
|
|
portfolioReturnsProvider.overrideWith(
|
|
(ref) => const Cached(<ReturnsOut>[]),
|
|
),
|
|
holdingsProvider.overrideWith((ref) => const Cached(<HoldingOut>[])),
|
|
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',
|
|
),
|
|
]),
|
|
),
|
|
]),
|
|
);
|
|
|
|
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',
|
|
);
|
|
});
|
|
}
|