Files
BalanceMono/BalanceFlutter/test/finance_test.dart
T
wt 2c278044bb
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
delete CloudKit and added BalanceFlutter
2026-10-28 15:37:32 +07:00

241 lines
7.8 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:balance_flutter/domain/finance.dart';
void main() {
final january = DateTime.utc(2026, 1);
final february = DateTime.utc(2026, 2);
TransactionRecord record(
String id,
int amount, {
String kind = 'expense',
DateTime? date,
bool adjustment = false,
String category = 'Продукты',
String note = '',
}) => TransactionRecord(
id: id,
amountMinor: amount,
date: date ?? january,
categoryName: category,
updatedAt: january,
kindRawValue: kind,
isBalanceAdjustment: adjustment,
note: note,
);
test('money input is exact and rejects invalid precision', () {
expect(parseMoney(' 123,45 '), 12345);
expect(parseMoney('0.1'), 10);
expect(parseMoney('10'), 1000);
expect(formatMinor(-101), '-1.01');
for (final invalid in ['-1', 'NaN', '1.001', '1e3', '']) {
expect(() => parseMoney(invalid), throwsFormatException);
}
expect(() => moneyToMinor(double.infinity), throwsFormatException);
});
test(
'wire payload preserves native keys, double money and UTC timestamps',
() {
final original = record('test', 12345, note: 'Кофе');
final payload = original.payload();
expect(payload['amount'], 123.45);
expect(payload['kindRawValue'], 'expense');
expect(payload.containsKey('id'), isFalse);
expect(payload['date'], '2026-01-01T00:00:00.000Z');
final roundTrip = TransactionRecord.fromJson(
jsonDecode(jsonEncode(original.toJson())) as Map<String, dynamic>,
);
expect(roundTrip.amountMinor, 12345);
expect(roundTrip.note, 'Кофе');
expect(roundTrip.updatedAt, january);
expect(roundTrip.copyWith(amountMinor: 10).id, 'test');
expect(original.amountMinor, 12345);
},
);
test('local JSON keeps exact minor integers independent of wire doubles', () {
const largeMinor = 9007199254740901;
final transaction = record('large', largeMinor);
final restored = TransactionRecord.fromJson(
jsonDecode(jsonEncode(transaction.toJson())) as Map<String, dynamic>,
);
expect(restored.amountMinor, largeMinor);
expect(transaction.payload().containsKey('amountMinor'), isFalse);
final budget = BudgetRecord(
id: 'large-budget',
categoryName: 'Дом',
limitMinor: largeMinor,
monthStart: january,
updatedAt: january,
);
expect(
BudgetRecord.fromJson(
jsonDecode(jsonEncode(budget.toJson())) as Map<String, dynamic>,
).limitMinor,
largeMinor,
);
expect(budget.payload().containsKey('limitMinor'), isFalse);
});
test('native fractional RFC3339 with timezone is supported', () {
final payload = record('test', 10).payload()
..['date'] = '2026-01-01T03:00:00.123456789+03:00';
final item = TransactionRecord.fromPayload(
id: 'test',
updatedAt: january,
payload: payload,
);
expect(item.date.toUtc(), DateTime.utc(2026, 1, 1, 0, 0, 0, 123, 456));
});
test(
'summary uses half-open period and excludes adjustments; balance includes them',
() {
final items = [
record('a', 10),
record('b', 20),
record('c', 100, kind: 'income'),
record('d', 200, kind: 'income', adjustment: true),
record('e', 1000, date: february),
record(
'f',
500,
date: january.subtract(const Duration(microseconds: 1)),
),
];
expect(FinanceMath.totalBalance(items), -1230);
final summary = FinanceMath.summary(items, january, february);
expect(summary.incomeMinor, 100);
expect(summary.expensesMinor, 30);
expect(summary.balanceMinor, 70);
},
);
test('decimal accumulation is exact for repeated hundredths', () {
final items = List.generate(100, (i) => record('$i', 10));
expect(FinanceMath.summary(items, january, february).expensesMinor, 1000);
expect(FinanceMath.totalBalance(items), -1000);
});
test(
'category spending groups names, orders amounts, excludes adjustments',
() {
final items = [
record('a', 100, category: 'Дом'),
record('b', 150),
record('c', 100, category: 'Дом'),
record('d', 999, adjustment: true),
record('e', 999, kind: 'income'),
];
final totals = FinanceMath.spending(items, january, february);
expect(totals.map((x) => x.name), ['Дом', 'Продукты']);
expect(totals.map((x) => x.amountMinor), [200, 150]);
},
);
test('monthly trend fills empty months and handles year boundary', () {
final trend = FinanceMath.monthlyTrend(
[record('a', 300)],
february,
months: 3,
);
expect(trend.map((x) => x.month), [
DateTime.utc(2025, 12),
january,
february,
]);
expect(trend.map((x) => x.summary.expensesMinor), [0, 300, 0]);
expect(
FinanceMath.nextMonth(DateTime.utc(2024, 12)),
DateTime.utc(2025, 1),
);
});
test('budget uses matching category expenses in its own calendar month', () {
final budget = BudgetRecord(
id: 'budget',
categoryName: 'Продукты',
limitMinor: 1000,
monthStart: DateTime.utc(2026, 1, 12),
updatedAt: january,
);
final items = [
record('a', 300),
record('b', 800, category: 'Дом'),
record('c', 999, kind: 'income'),
record('d', 999, date: february),
record('e', 999, adjustment: true),
];
expect(FinanceMath.budgetUsed(budget, items), 300);
expect(BudgetRecord.fromJson(budget.toJson()).limitMinor, 1000);
expect(budget.payload()['limit'], 10.0);
});
test('wire budget month is interpreted in the local calendar', () {
final localJanuary = DateTime(2026, 1);
final localExpense = DateTime(2026, 1, 15);
final localDecember = DateTime(2025, 12, 15);
final original = BudgetRecord(
id: 'wire-budget',
categoryName: 'Продукты',
limitMinor: 10000,
monthStart: localJanuary,
updatedAt: january,
);
final imported = BudgetRecord.fromJson(original.toJson());
expect(imported.monthStart.isUtc, isTrue);
final items = [
record('jan', 300, date: localExpense.toUtc()),
record('dec', 900, date: localDecember.toUtc()),
];
expect(FinanceMath.budgetUsed(imported, items), 300);
});
test('category and tombstone round trip with protocol fields', () {
final category = BuiltInCategories.income.first;
expect(
CategoryRecord.fromJson(category.toJson()).kind,
TransactionKind.income,
);
final tombstone = Tombstone(
entity: 'transaction',
id: 'deleted',
updatedAt: january,
);
expect(tombstone.toJson()['deleted'], isTrue);
expect(tombstone.toJson().containsKey('payload'), isFalse);
expect(Tombstone.fromJson(tombstone.toJson()).recordID, 'deleted');
});
test('design catalog matches native apps', () {
expect(CategoryDesignOptions.icons.length, 50);
expect(CategoryDesignOptions.colors.length, 24);
expect(CategoryDesignOptions.emojis.length, 30);
expect(BuiltInCategories.expense.length, 9);
expect(BuiltInCategories.income.length, 5);
});
test(
'CSV quotes commas, double quotes, newlines and includes adjustments',
() {
final csv = FinanceMath.exportCsv([
record('a', 123, note: 'кофе, "чай"\nеда'),
record(
'b',
400,
date: january.add(const Duration(days: 1)),
adjustment: true,
),
]);
expect(csv, startsWith('Дата,Тип,Категория,Сумма,Заметка\n'));
expect(csv, contains('"кофе, ""чай""\nеда"'));
expect(csv, contains('"1.23"'));
expect(csv.indexOf('Корректировка'), lessThan(csv.indexOf('кофе')));
expect(FinanceMath.escapeCsv('a\rb'), '"a\rb"');
},
);
}