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 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 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([])), portfolioReturnsProvider.overrideWith((ref) => const Cached([])), 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([])), ], )); 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([])), portfolioReturnsProvider.overrideWith((ref) => const Cached([])), holdingsProvider.overrideWith((ref) => const Cached([])), benchmarkRowsProvider.overrideWith((ref) => const Cached([])), ], )); 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'); }); }