Files
fin-tracker/app/test/phase4_models_test.dart
T
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00

451 lines
15 KiB
Dart

import 'package:decimal/decimal.dart';
import 'package:fintracker_app/features/goals/data/goals_api.dart';
import 'package:fintracker_app/features/income/data/income_api.dart';
import 'package:fintracker_app/features/portfolio/data/benchmarks_api.dart';
import 'package:fintracker_app/features/rebalance/data/rebalance_api.dart';
import 'package:fintracker_app/features/rebalance/weights.dart';
import 'package:fintracker_app/features/tax/data/tax_api.dart';
import 'package:flutter_test/flutter_test.dart';
/// The exact payloads from `docs/ai/phase4-contract.md`. If the backend renames a field or
/// changes a shape, these tests are the first thing that notices — the hand-written layer
/// has no generated code to fail the build for it.
void main() {
group('income', () {
const calendarJson = <String, dynamic>{
'as_of': '2026-09-18',
'currency': 'RUB',
'total_expected_rub': '14230.50',
'entries': [
{
'instrument_id': 88,
'ticker': 'SBER',
'name': 'Сбербанк России',
'kind': 'dividend',
'expected_date': '2026-10-12',
'record_date': '2026-10-09',
'qty': '20',
'per_unit': '34.84',
'amount': '696.80',
'currency': 'RUB',
'amount_rub': '696.80',
'basis': 'announced',
'tax_withheld': null,
},
],
'by_basis': {
'schedule': '8100.00',
'announced': '4130.50',
'history': '2000.00',
},
};
test('parses the calendar, keeping every amount as a string', () {
final c = IncomeCalendar.fromJson(calendarJson);
expect(c.asOf, DateTime.utc(2026, 9, 18));
expect(c.totalExpectedRub, '14230.50');
expect(c.byBasis['history'], '2000.00');
expect(c.entries.single.basis, 'announced');
expect(c.entries.single.ticker, 'SBER');
expect(c.entries.single.taxWithheld, isNull);
expect(
c.entries.single.perUnit,
'34.84',
reason: 'never parsed through double',
);
});
test('a null amount_rub stays null — no FX rate is not zero', () {
final json = Map<String, dynamic>.from(calendarJson);
json['entries'] = [
{
...(calendarJson['entries'] as List).first as Map<String, dynamic>,
'amount_rub': null,
},
];
expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull);
});
test('parses the history rows and totals', () {
final h = IncomeHistory.fromJson(const {
'rows': [
{
'month': '2026-08-01',
'kind': 'coupon',
'currency': 'RUB',
'amount': '1204.11',
'amount_rub': '1204.11',
'tax_withheld': '156.00',
'payment_count': 3,
},
],
'totals': {'amount_rub': '24518.30', 'tax_withheld_rub': '3187.00'},
});
expect(h.rows.single.month, DateTime.utc(2026, 8));
expect(h.rows.single.paymentCount, 3);
expect(h.totalRub, '24518.30');
expect(h.taxWithheldRub, '3187.00');
});
test('parses the forecast with its by_basis split and null yield', () {
final f = IncomeForecast.fromJson(const {
'months': [
{
'month': '2026-10-01',
'amount_rub': '1830.20',
'by_basis': {
'schedule': '1133.40',
'announced': '696.80',
'history': '0',
},
},
],
'total_rub': '21960.00',
'annual_yield_on_value': '0.081',
'warnings': [
'у 3 инструментов нет истории выплат — в прогноз не вошли',
],
});
expect(f.months.single.byBasis['schedule'], '1133.40');
expect(f.totalRub, '21960.00');
expect(f.annualYieldOnValue, '0.081');
expect(f.warnings, hasLength(1));
expect(f.bases, [
'schedule',
'announced',
'history',
], reason: 'contract order');
final noYield = IncomeForecast.fromJson(const {
'months': <dynamic>[],
'total_rub': '0',
'annual_yield_on_value': null,
});
expect(noYield.annualYieldOnValue, isNull);
});
});
group('rebalance', () {
test('parses a target set and validates the sum exactly', () {
final set = TargetSet.fromJson(const {
'dimension': 'asset_class',
'weights_sum': '1.00',
'targets': [
{
'bucket': 'share',
'target_weight': '0.60',
'band': '0.05',
'note': null,
},
{'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'},
{'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'},
],
});
expect(set.targets, hasLength(3));
expect(set.localSum, Decimal.one);
expect(set.sumIsValid, isTrue);
expect(set.targets.first.band, '0.05');
// 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double
final tenths = TargetSet(
dimension: 'asset_class',
targets: const [
TargetWeight(bucket: 'a', targetWeight: '0.1'),
TargetWeight(bucket: 'b', targetWeight: '0.2'),
TargetWeight(bucket: 'c', targetWeight: '0.7'),
],
);
expect(tenths.sumIsValid, isTrue);
final short = TargetSet(
dimension: 'asset_class',
targets: const [TargetWeight(bucket: 'a', targetWeight: '0.9')],
);
expect(short.sumIsValid, isFalse);
});
test('the PUT body omits empty optionals and keeps shares as strings', () {
final body = const TargetWeight(
bucket: 'bond',
targetWeight: '0.30',
band: '0.05',
).toJson();
expect(body['target_weight'], '0.30');
expect(body.containsKey('note'), isFalse);
});
test('percent input round-trips through shares without float error', () {
expect(percentTextToShare('60'), '0.6');
expect(percentTextToShare('33,33'), '0.3333');
expect(shareToPercentText('0.3333'), '33,33');
expect(shareToPercentText('0.60'), '60');
expect(percentTextToShare('abc'), isNull);
expect(formatShareAsPercent('0.032', signed: true), '+3,20 %');
expect(formatShareAsPercent(null), '—');
});
test(
'parses the rebalance plan, including within_band and blocked_by_cash',
() {
final plan = RebalancePlan.fromJson(const {
'portfolio_id': 1,
'dimension': 'asset_class',
'as_of': '2026-09-18',
'total_value_rub': '1284300.00',
'cash_available_rub': '48120.87',
'buckets': [
{
'bucket': 'share',
'current_value_rub': '812000.00',
'current_weight': '0.632',
'target_weight': '0.60',
'drift': '0.032',
'within_band': true,
'delta_value_rub': '-41420.00',
'trades': [
{
'instrument_id': 88,
'ticker': 'SBER',
'name': 'Сбербанк России',
'action': 'sell',
'suggested_qty': '150',
'lot': 10,
'price': '275.89',
'price_currency': 'RUB',
'amount_rub': '41383.50',
'blocked_by_cash': false,
},
],
},
],
'warnings': ['у 2 инструментов нет цены — в рекомендации не вошли'],
});
expect(plan.portfolioId, 1);
expect(plan.buckets.single.withinBand, isTrue);
expect(plan.everythingWithinBand, isTrue);
final trade = plan.buckets.single.trades.single;
expect(trade.action, 'sell');
expect(trade.suggestedQty, '150');
expect(trade.lot, 10);
expect(trade.blockedByCash, isFalse);
expect(plan.warnings, hasLength(1));
},
);
test('suggested_qty stays null when there is no price', () {
final trade = RebalanceTrade.fromJson(const {
'instrument_id': 91,
'ticker': 'SIBN6P4',
'action': 'buy',
'suggested_qty': null,
'lot': 1,
'price': null,
'amount_rub': null,
'blocked_by_cash': false,
});
expect(trade.suggestedQty, isNull, reason: 'null is «нет цены», never 0');
expect(trade.price, isNull);
});
});
group('benchmarks', () {
test(
'parses a period row with excess, kind and days_skipped on both sides',
() {
final rows = [
for (final r in const [
{
'period': '1y',
'date_from': '2025-09-18',
'date_to': '2026-09-18',
'portfolio_twr': '0.184',
'portfolio_twr_annualized': '0.184',
'portfolio_days_skipped': 3,
'benchmarks': [
{
'benchmark_id': 1,
'code': 'MCFTR',
'kind': 'total_return',
'twr': '0.121',
'twr_annualized': '0.121',
'days_skipped': 0,
'excess': '0.063',
},
{
'benchmark_id': 2,
'code': 'IMOEX',
'kind': 'price',
'twr': '0.084',
'days_skipped': 2,
'excess': '0.100',
},
],
},
])
BenchmarkRow.fromJson(r),
];
final row = rows.single;
expect(row.period, '1y');
expect(row.portfolioDaysSkipped, 3);
expect(row.hasSkippedDays, isTrue);
expect(row.benchmarks.first.isPriceIndex, isFalse);
expect(
row.benchmarks.last.isPriceIndex,
isTrue,
reason: 'a price index must be markable',
);
expect(row.benchmarks.first.excess, '0.063');
},
);
});
group('goals', () {
test('parses a goal and builds a body without the id', () {
final goal = Goal.fromJson(const {
'id': 3,
'name': 'Подушка',
'scope': 'account:12',
'target_amount': '1000000',
'currency': 'RUB',
'target_date': '2028-01-01',
'monthly_contribution': '30000',
'note': null,
'archived': false,
});
expect(goal.id, 3);
expect(goal.targetDate, DateTime.utc(2028));
final body = goal.toJson();
expect(body.containsKey('id'), isFalse);
expect(body['target_date'], '2028-01-01');
expect(body.containsKey('note'), isFalse);
});
test('parses progress with a projected date', () {
final p = GoalProgress.fromJson(const {
'goal_id': 3,
'as_of': '2026-09-18',
'current_value_rub': '412800.00',
'target_amount_rub': '1000000.00',
'progress': '0.4128',
'projected_date': '2027-11-14',
'basis': 'xirr',
'assumed_rate': '0.142',
'monthly_needed_rub': '42300.00',
'on_track': false,
});
expect(p.projectedDate, DateTime.utc(2027, 11, 14));
expect(p.isUnreachable, isFalse);
expect(p.basis, 'xirr');
expect(p.onTrack, isFalse);
});
test('a null projected_date means «не достигается», not «неизвестно»', () {
final p = GoalProgress.fromJson(const {
'goal_id': 3,
'progress': '0.05',
'projected_date': null,
'basis': 'contribution',
'monthly_needed_rub': '99000.00',
'on_track': false,
});
expect(p.projectedDate, isNull);
expect(p.isUnreachable, isTrue);
// basis "none" is the other case: nothing to project from at all
final noBasis = GoalProgress.fromJson(const {
'goal_id': 4,
'projected_date': null,
'basis': 'none',
'on_track': false,
});
expect(noBasis.isUnreachable, isFalse);
});
});
group('tax', () {
test('parses the year summary, keeping estimated и disclaimer', () {
final s = TaxSummary.fromJson(const {
'year': 2026,
'estimated': true,
'tax_rate': '0.13',
'accounts': [
{
'account_id': 12,
'account_name': 'ИИС Сбер',
'dividends_gross_rub': '12400.00',
'coupons_gross_rub': '8100.00',
'tax_withheld_rub': '2665.00',
'realized_gain_rub': '31200.00',
'realized_loss_rub': '-4100.00',
'ldv_exempt_rub': '12000.00',
'taxable_base_rub': '15100.00',
'estimated_tax_rub': '1963.00',
},
],
'totals': {
'dividends_gross_rub': '12400.00',
'estimated_tax_rub': '1963.00',
},
'disclaimer':
'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.',
});
expect(s.year, 2026);
expect(s.estimated, isTrue);
expect(s.taxRate, '0.13');
expect(s.accounts.single.accountName, 'ИИС Сбер');
expect(s.accounts.single.realizedLossRub, '-4100.00');
expect(s.totals?.estimatedTaxRub, '1963.00');
expect(s.disclaimer, contains('Оценка'));
});
test('estimated defaults to true when the field is missing', () {
expect(TaxSummary.fromJson(const {'year': 2026}).estimated, isTrue);
});
test('parses lots and flags the ones close to ЛДВ', () {
final lots = [
for (final l in const [
{
'lot_id': 812,
'instrument_id': 88,
'ticker': 'SBER',
'account_id': 12,
'open_date': '2024-03-14',
'qty_remaining': '20',
'cost_rub': '4800.00',
'market_value_rub': '5517.80',
'unrealized_gain_rub': '717.80',
'ldv_eligible': false,
'ldv_date': '2027-03-14',
'days_to_ldv': 177,
'tax_if_sold_now_rub': '93.31',
},
{
'lot_id': 813,
'ticker': 'LKOH',
'ldv_eligible': false,
'days_to_ldv': 400,
},
{
'lot_id': 814,
'ticker': 'GAZP',
'ldv_eligible': true,
'days_to_ldv': 0,
},
])
TaxLot.fromJson(l),
];
expect(lots[0].ldvDate, DateTime.utc(2027, 3, 14));
expect(lots[0].daysToLdv, 177);
expect(lots[0].nearLdv, isTrue, reason: '177 дн. — меньше полугода');
expect(lots[1].nearLdv, isFalse);
expect(lots[2].nearLdv, isFalse, reason: 'ЛДВ уже действует');
expect(lots[0].taxIfSoldNowRub, '93.31');
});
});
}