delete CloudKit and added BalanceFlutter
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
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
This commit is contained in:
1 parent
a715eca947
commit
2c278044bb
214 files changed
+13411
-2290
No files matched your search
@@ -0,0 +1,520 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:balance_flutter/domain/finance.dart';
|
||||
import 'package:balance_flutter/services/app_store.dart';
|
||||
import 'package:balance_flutter/services/storage.dart';
|
||||
import 'package:balance_flutter/services/sync_api.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
});
|
||||
final date = DateTime.utc(2026, 1);
|
||||
TransactionRecord transaction(String id) => TransactionRecord(
|
||||
id: id,
|
||||
amountMinor: 12345,
|
||||
date: date,
|
||||
categoryName: 'Еда',
|
||||
updatedAt: date,
|
||||
);
|
||||
|
||||
test('durable restart keeps edits and deletion tombstones', () async {
|
||||
final storage = MemorySnapshotStorage();
|
||||
final store = AppStore(storage: storage);
|
||||
await store.initialize();
|
||||
await store.saveTransaction(transaction('keep'));
|
||||
await store.saveTransaction(transaction('delete'));
|
||||
await store.deleteTransaction('delete');
|
||||
final reopened = AppStore(storage: storage);
|
||||
await reopened.initialize();
|
||||
expect(reopened.transactions.single.id, 'keep');
|
||||
expect(reopened.transactions.single.amountMinor, 12345);
|
||||
expect(reopened.pendingChangeCount, 2);
|
||||
expect(storage.values.values.single['tombstones'], hasLength(1));
|
||||
reopened.dispose();
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'category rename cascades to transactions and budgets in one snapshot',
|
||||
() async {
|
||||
final storage = MemorySnapshotStorage();
|
||||
final store = AppStore(storage: storage);
|
||||
await store.initialize();
|
||||
final category = CategoryRecord(
|
||||
id: 'cat',
|
||||
name: 'Еда',
|
||||
icon: 'cart.fill',
|
||||
colorName: 'orange',
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
);
|
||||
await store.saveCategory(category);
|
||||
await store.saveTransaction(transaction('tx'));
|
||||
await store.saveBudget(
|
||||
BudgetRecord(
|
||||
id: 'budget',
|
||||
categoryName: 'Еда',
|
||||
limitMinor: 10000,
|
||||
monthStart: date,
|
||||
updatedAt: date,
|
||||
),
|
||||
);
|
||||
await store.saveCategory(
|
||||
category.copyWith(name: 'Продукты', emoji: '🍏'),
|
||||
);
|
||||
expect(store.transactions.single.categoryName, 'Продукты');
|
||||
expect(store.budgets.single.categoryName, 'Продукты');
|
||||
expect(store.transactions.single.categoryEmoji, '🍏');
|
||||
expect(store.pendingChangeCount, 3);
|
||||
store.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pending delete survives newer remote edit and is queued with newer timestamp',
|
||||
() async {
|
||||
final storage = MemorySnapshotStorage();
|
||||
final remoteTime = DateTime.now().toUtc().add(const Duration(seconds: 2));
|
||||
var serveConflict = false;
|
||||
final client = MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map;
|
||||
if (request.url.path.endsWith('/login')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'accessToken': 'access',
|
||||
'refreshToken': 'refresh',
|
||||
'expiresAt': DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(hours: 1))
|
||||
.toIso8601String(),
|
||||
'user': {'id': 'user', 'email': 'test@example.com'},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
final conflict = serveConflict && body['pull'] == true;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'cursor': conflict ? 1 : body['cursor'],
|
||||
'hasMore': false,
|
||||
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
||||
'changes': conflict
|
||||
? [
|
||||
{
|
||||
'entity': 'transaction',
|
||||
'id': 'tx',
|
||||
'deleted': false,
|
||||
'updatedAt': remoteTime.toIso8601String(),
|
||||
'sequence': 1,
|
||||
'payload': transaction('tx').payload(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final store = AppStore(
|
||||
storage: storage,
|
||||
apiFactory: (server) => SyncApi(serverUrl: server, client: client),
|
||||
);
|
||||
await store.initialize();
|
||||
await store.setServerUrl('https://balance.example.com');
|
||||
await store.signIn('test@example.com', 'password');
|
||||
await store.saveTransaction(transaction('tx'));
|
||||
await store.deleteTransaction('tx');
|
||||
serveConflict = true;
|
||||
await store.sync();
|
||||
expect(store.transactions, isEmpty);
|
||||
expect(store.pendingChangeCount, 1);
|
||||
expect(store.cursor, 1);
|
||||
final saved = storage.values['https://balance.example.com|user']!;
|
||||
final pending = (saved['dirty'] as List).single as Map;
|
||||
expect(pending['deleted'], true);
|
||||
expect(
|
||||
DateTime.parse(pending['updatedAt'] as String).isAfter(remoteTime),
|
||||
true,
|
||||
);
|
||||
store.dispose();
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
test(
|
||||
'expense category deletion removes budgets and preserves history',
|
||||
() async {
|
||||
final store = AppStore(storage: MemorySnapshotStorage());
|
||||
await store.initialize();
|
||||
await store.saveCategory(
|
||||
CategoryRecord(
|
||||
id: 'cat',
|
||||
name: 'Еда',
|
||||
icon: 'cart.fill',
|
||||
colorName: 'orange',
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
),
|
||||
);
|
||||
await store.saveTransaction(transaction('tx'));
|
||||
await store.saveBudget(
|
||||
BudgetRecord(
|
||||
id: 'budget',
|
||||
categoryName: 'Еда',
|
||||
limitMinor: 10000,
|
||||
monthStart: date,
|
||||
updatedAt: date,
|
||||
),
|
||||
);
|
||||
await store.deleteCategory('cat');
|
||||
expect(store.categories, isEmpty);
|
||||
expect(store.budgets, isEmpty);
|
||||
expect(store.transactions, hasLength(1));
|
||||
expect(store.pendingChangeCount, 3);
|
||||
store.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'failed pull commit preserves old cursor and dirty local changes',
|
||||
() async {
|
||||
final storage = FailingStorage();
|
||||
var includeRemote = false;
|
||||
final client = MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map;
|
||||
if (request.url.path.endsWith('/login')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'accessToken': 'a',
|
||||
'refreshToken': 'r',
|
||||
'expiresAt': DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(hours: 1))
|
||||
.toIso8601String(),
|
||||
'user': {'id': 'u', 'email': 'u@example.com'},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
final pull = body['pull'] == true && includeRemote;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'cursor': pull ? 1 : body['cursor'],
|
||||
'hasMore': false,
|
||||
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
||||
'changes': pull
|
||||
? [
|
||||
{
|
||||
'entity': 'transaction',
|
||||
'id': 'remote',
|
||||
'deleted': false,
|
||||
'updatedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'sequence': 1,
|
||||
'payload': transaction('remote').payload(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final store = AppStore(
|
||||
storage: storage,
|
||||
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
||||
);
|
||||
await store.initialize();
|
||||
await store.setServerUrl('https://example.com');
|
||||
await store.signIn('u@example.com', 'password');
|
||||
await store.saveTransaction(transaction('local'));
|
||||
includeRemote = true;
|
||||
storage.failPage = true;
|
||||
await expectLater(store.sync(), throwsStateError);
|
||||
expect(store.cursor, 0);
|
||||
expect(store.transactions.map((e) => e.id), ['local']);
|
||||
expect(store.pendingChangeCount, 1);
|
||||
expect(storage.values['https://example.com|u']!['cursor'], 0);
|
||||
storage.failPage = false;
|
||||
await store.sync();
|
||||
expect(store.cursor, 1);
|
||||
expect(store.transactions, hasLength(2));
|
||||
expect(store.pendingChangeCount, 0);
|
||||
store.dispose();
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'first account imports guest once; accounts and servers stay isolated',
|
||||
() async {
|
||||
final storage = MemorySnapshotStorage();
|
||||
final client = MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map;
|
||||
if (request.url.path.endsWith('/login')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'accessToken': 'a',
|
||||
'refreshToken': 'r',
|
||||
'expiresAt': DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(hours: 1))
|
||||
.toIso8601String(),
|
||||
'user': {'id': body['email'], 'email': body['email']},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
if (request.url.path.endsWith('/logout')) {
|
||||
return http.Response('{}', 200);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'cursor': body['cursor'],
|
||||
'changes': [],
|
||||
'hasMore': false,
|
||||
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final store = AppStore(
|
||||
storage: storage,
|
||||
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
||||
);
|
||||
await store.initialize();
|
||||
await store.setServerUrl('https://one.example.com');
|
||||
await store.saveTransaction(transaction('guest'));
|
||||
await store.signIn('one@example.com', 'password');
|
||||
expect(store.transactions.single.id, 'guest');
|
||||
await store.signOut();
|
||||
await store.signIn('two@example.com', 'password');
|
||||
expect(store.transactions, isEmpty);
|
||||
await store.saveTransaction(transaction('second'));
|
||||
await store.setServerUrl('https://two.example.com');
|
||||
expect(store.session, isNull);
|
||||
expect(store.cursor, 0);
|
||||
expect(store.transactions, isEmpty);
|
||||
await store.setServerUrl('https://one.example.com');
|
||||
await store.signIn('two@example.com', 'password');
|
||||
expect(store.transactions.single.id, 'second');
|
||||
store.dispose();
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
test(
|
||||
'two stores reload latest ledger before writing instead of clobbering it',
|
||||
() async {
|
||||
final storage = MemorySnapshotStorage();
|
||||
final first = AppStore(storage: storage, background: true);
|
||||
final second = AppStore(storage: storage, background: true);
|
||||
await first.initialize();
|
||||
await second.initialize();
|
||||
await first.saveTransaction(transaction('one'));
|
||||
await second.saveTransaction(transaction('two'));
|
||||
await first.saveTransaction(transaction('three'));
|
||||
expect(first.transactions.map((t) => t.id).toSet(), {
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
});
|
||||
await second.sync();
|
||||
expect(second.transactions, hasLength(3));
|
||||
await first.close();
|
||||
await second.close();
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'headless initialization skips automatic sync and reloads another engine logout',
|
||||
() async {
|
||||
final storage = MemorySnapshotStorage();
|
||||
var syncRequests = 0;
|
||||
final client = MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map;
|
||||
if (request.url.path.endsWith('/login')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'accessToken': 'a',
|
||||
'refreshToken': 'r',
|
||||
'expiresAt': DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(hours: 1))
|
||||
.toIso8601String(),
|
||||
'user': {'id': 'u', 'email': 'u@example.com'},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.url.path.endsWith('/logout')) {
|
||||
return http.Response('{}', 200);
|
||||
}
|
||||
syncRequests++;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'cursor': body['cursor'],
|
||||
'changes': [],
|
||||
'hasMore': false,
|
||||
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final first = AppStore(
|
||||
storage: storage,
|
||||
background: true,
|
||||
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
||||
);
|
||||
await first.initialize();
|
||||
await first.setServerUrl('https://example.com');
|
||||
await first.signIn('u@example.com', 'password');
|
||||
await first.saveTransaction(transaction('private'));
|
||||
final countBeforeInitialize = syncRequests;
|
||||
final second = AppStore(
|
||||
storage: storage,
|
||||
background: true,
|
||||
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
||||
);
|
||||
await second.initialize();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(syncRequests, countBeforeInitialize);
|
||||
expect(second.transactions.single.id, 'private');
|
||||
await first.signOut();
|
||||
await second.sync();
|
||||
expect(second.session, isNull);
|
||||
expect(second.transactions, isEmpty);
|
||||
expect(syncRequests, countBeforeInitialize);
|
||||
await first.close();
|
||||
await second.close();
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
test(
|
||||
'second engine reloads rotated refresh session instead of reusing old token',
|
||||
() async {
|
||||
const server = 'https://example.com';
|
||||
Map<String, dynamic> sessionData(String suffix, DateTime expiry) => {
|
||||
'accessToken': 'access-$suffix',
|
||||
'refreshToken': 'refresh-$suffix',
|
||||
'expiresAt': expiry.toUtc().toIso8601String(),
|
||||
'user': {'id': 'u', 'email': 'u@example.com'},
|
||||
};
|
||||
SharedPreferences.setMockInitialValues({'serverUrl': server});
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'balance.session.${Uri.encodeComponent(server)}': jsonEncode(
|
||||
sessionData('old', DateTime.now().subtract(const Duration(hours: 1))),
|
||||
),
|
||||
});
|
||||
var refreshes = 0;
|
||||
final authorization = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map;
|
||||
if (request.url.path.endsWith('/refresh')) {
|
||||
refreshes++;
|
||||
expect(body['refreshToken'], 'refresh-old');
|
||||
return http.Response(
|
||||
jsonEncode(
|
||||
sessionData('new', DateTime.now().add(const Duration(hours: 1))),
|
||||
),
|
||||
200,
|
||||
);
|
||||
}
|
||||
authorization.add(request.headers['authorization']!);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'cursor': body['cursor'],
|
||||
'changes': [],
|
||||
'hasMore': false,
|
||||
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final storage = MemorySnapshotStorage();
|
||||
final first = AppStore(
|
||||
storage: storage,
|
||||
background: true,
|
||||
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
||||
);
|
||||
final second = AppStore(
|
||||
storage: storage,
|
||||
background: true,
|
||||
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
||||
);
|
||||
await first.initialize();
|
||||
await second.initialize();
|
||||
await first.sync();
|
||||
await second.sync();
|
||||
expect(refreshes, 1);
|
||||
expect(authorization, ['Bearer access-new', 'Bearer access-new']);
|
||||
expect(second.session!.refreshToken, 'refresh-new');
|
||||
await first.close();
|
||||
await second.close();
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
test(
|
||||
'invalid server URL preserves the active account and its ledger',
|
||||
() async {
|
||||
final url = 'https://good.example';
|
||||
SharedPreferences.setMockInitialValues({'serverUrl': url});
|
||||
final credentials = {
|
||||
'accessToken': 'access',
|
||||
'refreshToken': 'refresh',
|
||||
'expiresAt': DateTime.now()
|
||||
.add(const Duration(hours: 1))
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
'user': {'id': 'account', 'email': 'a@example.com'},
|
||||
};
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'balance.session.${Uri.encodeComponent(url)}': jsonEncode(credentials),
|
||||
});
|
||||
final store = AppStore(
|
||||
storage: MemorySnapshotStorage(),
|
||||
background: true,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveTransaction(transaction('preserved'));
|
||||
for (final invalid in [
|
||||
'http://remote.example',
|
||||
'https://name:secret@remote.example',
|
||||
'https://remote.example?x=1',
|
||||
'https://remote.example#fragment',
|
||||
'/relative',
|
||||
]) {
|
||||
await expectLater(store.setServerUrl(invalid), throwsArgumentError);
|
||||
expect(store.serverUrl, url);
|
||||
expect(store.session!.userId, 'account');
|
||||
expect(store.transactions.single.id, 'preserved');
|
||||
}
|
||||
await store.close();
|
||||
store.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class FailingStorage extends MemorySnapshotStorage {
|
||||
bool failPage = false;
|
||||
@override
|
||||
Future<void> write(String key, Map<String, dynamic> value) async {
|
||||
if (failPage && value['cursor'] == 1) throw StateError('Disk full');
|
||||
await super.write(key, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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"');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:balance_flutter/services/runtime_lock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('balance/runtime');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
tearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
test('Android release carries the acquired engine token', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return call.method == 'acquireLock' ? 'owner-token' : null;
|
||||
});
|
||||
final lock = RuntimeLock();
|
||||
await lock.acquire();
|
||||
await expectLater(lock.acquire(), throwsStateError);
|
||||
await lock.release();
|
||||
expect(calls.map((call) => call.method), ['acquireLock', 'releaseLock']);
|
||||
expect(calls.last.arguments, {'token': 'owner-token'});
|
||||
await expectLater(lock.release(), throwsStateError);
|
||||
});
|
||||
|
||||
test('missing native ownership token fails closed', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
messenger.setMockMethodCallHandler(channel, (_) async => null);
|
||||
await expectLater(RuntimeLock().acquire(), throwsStateError);
|
||||
});
|
||||
|
||||
test('non-Android platforms never invoke the native lock channel', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
messenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
(_) async => fail('Unexpected native channel call'),
|
||||
);
|
||||
final lock = RuntimeLock();
|
||||
await lock.acquire();
|
||||
await lock.release();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:balance_flutter/ui/app.dart';
|
||||
import 'package:balance_flutter/ui/editors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'widget_test.dart' as fixtures;
|
||||
|
||||
Future<void> capture(
|
||||
WidgetTester tester,
|
||||
GlobalKey key,
|
||||
Size size,
|
||||
String name,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
final boundary =
|
||||
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final image = await boundary.toImage(pixelRatio: 1);
|
||||
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
final dir = Directory('/tmp/balance-ui')..createSync(recursive: true);
|
||||
File(
|
||||
'${dir.path}/${size.width.toInt()}x${size.height.toInt()}-$name.png',
|
||||
).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
image.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(fixtures.loadReadableFont);
|
||||
setUp(fixtures.mockStorage);
|
||||
testWidgets('capture dark phone overview', (tester) async {
|
||||
final store = (await tester.runAsync(() => fixtures.createStore()))!;
|
||||
await tester.runAsync(() => store.setTheme('dark'));
|
||||
final key = GlobalKey();
|
||||
const size = Size(390, 844);
|
||||
await fixtures.boot(tester, store, size, boundaryKey: key);
|
||||
await capture(tester, key, size, 'overview-dark');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
for (final size in fixtures.screenSizes) {
|
||||
testWidgets(
|
||||
'capture screens ${size.width.toInt()}x${size.height.toInt()}',
|
||||
(tester) async {
|
||||
final store = (await tester.runAsync(() => fixtures.createStore()))!;
|
||||
final key = GlobalKey();
|
||||
await fixtures.boot(tester, store, size, boundaryKey: key);
|
||||
final names = [
|
||||
'overview',
|
||||
'transactions',
|
||||
'analytics',
|
||||
'budgets',
|
||||
'settings',
|
||||
];
|
||||
for (var page = 0; page < names.length; page++) {
|
||||
await fixtures.navigate(tester, page);
|
||||
expect(tester.takeException(), isNull);
|
||||
await capture(tester, key, size, names[page]);
|
||||
}
|
||||
final editors = <String, Widget>{
|
||||
'transaction-form': TransactionEditor(categories: store.categories),
|
||||
'category-form': CategoryEditor(categories: store.categories),
|
||||
'budget-form': BudgetEditor(
|
||||
categories: store.categoriesForKind('expense'),
|
||||
month: DateTime.now(),
|
||||
),
|
||||
};
|
||||
for (final entry in editors.entries) {
|
||||
editDialog<void>(
|
||||
tester.element(find.byType(BalanceShell)),
|
||||
entry.value,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
await capture(tester, key, size, entry.key);
|
||||
await fixtures.reveal(tester, find.text('Отмена'));
|
||||
await capture(tester, key, size, '${entry.key}-actions');
|
||||
await tester.tap(find.text('Отмена'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:balance_flutter/services/sync_api.dart';
|
||||
|
||||
void main() {
|
||||
const time = '2026-01-01T00:00:00Z';
|
||||
WireMap change(int sequence) => {
|
||||
'entity': 'transaction',
|
||||
'id': 'id-$sequence',
|
||||
'deleted': false,
|
||||
'updatedAt': time,
|
||||
'payload': {'amount': 1.23},
|
||||
'sequence': sequence,
|
||||
};
|
||||
http.Response response(Object value, {int status = 200}) => http.Response(
|
||||
jsonEncode(value),
|
||||
status,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
WireMap page(
|
||||
int cursor, {
|
||||
bool more = false,
|
||||
List<WireMap> changes = const [],
|
||||
}) => {
|
||||
'cursor': cursor,
|
||||
'hasMore': more,
|
||||
'changes': changes,
|
||||
'serverTime': time,
|
||||
};
|
||||
Future<WireMap> pull(SyncApi api, {int cursor = 0}) => api.sync(
|
||||
accessToken: 'test-token',
|
||||
cursor: cursor,
|
||||
deviceId: 'test-device',
|
||||
changes: [],
|
||||
);
|
||||
|
||||
test('auth uses native routes, trims email and preserves password', () async {
|
||||
final requests = <http.Request>[];
|
||||
final session = {
|
||||
'accessToken': 'access',
|
||||
'refreshToken': 'refresh',
|
||||
'expiresAt': time,
|
||||
'user': {'id': 'user', 'email': 'test@example.com'},
|
||||
};
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com/base/',
|
||||
client: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path.endsWith('/me')) return response(session['user']!);
|
||||
if (request.url.path.endsWith('/logout')) return http.Response('', 204);
|
||||
return response(session);
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await api.register(' test@example.com ', ' password '))['accessToken'],
|
||||
'access',
|
||||
);
|
||||
await api.login('test@example.com', ' password ');
|
||||
await api.refresh('refresh');
|
||||
await api.me('access');
|
||||
await api.logout('refresh');
|
||||
expect(requests.map((r) => r.url.path), [
|
||||
'/base/v1/auth/register',
|
||||
'/base/v1/auth/login',
|
||||
'/base/v1/auth/refresh',
|
||||
'/base/v1/me',
|
||||
'/base/v1/auth/logout',
|
||||
]);
|
||||
expect(jsonDecode(requests.first.body), {
|
||||
'email': 'test@example.com',
|
||||
'password': ' password ',
|
||||
});
|
||||
expect(requests[3].method, 'GET');
|
||||
expect(requests[3].headers['Authorization'], 'Bearer access');
|
||||
expect(requests.every((r) => !r.followRedirects), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'synchronize batches push-only by 100 then applies ordered pull pages',
|
||||
() async {
|
||||
final sent = <WireMap>[];
|
||||
var pullCount = 0;
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as WireMap;
|
||||
sent.add(body);
|
||||
if (body['pull'] == false) return response(page(0));
|
||||
pullCount++;
|
||||
return response(
|
||||
page(pullCount, more: pullCount == 1, changes: [change(pullCount)]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
final applied = <int>[];
|
||||
final outgoing = List.generate(205, (i) => change(i)..remove('sequence'));
|
||||
final result = await api.synchronize(
|
||||
accessToken: () async => 'token',
|
||||
cursor: 0,
|
||||
deviceId: 'device',
|
||||
changes: outgoing,
|
||||
applyPage: (changes, cursor) async {
|
||||
expect(changes.single['sequence'], cursor);
|
||||
applied.add(cursor);
|
||||
},
|
||||
);
|
||||
expect(sent.take(3).map((p) => (p['changes'] as List).length), [
|
||||
100,
|
||||
100,
|
||||
5,
|
||||
]);
|
||||
expect(
|
||||
sent.take(3).every((p) => p['pull'] == false && p['cursor'] == 0),
|
||||
isTrue,
|
||||
);
|
||||
expect(sent.skip(3).map((p) => p['cursor']), [0, 1]);
|
||||
expect(applied, [1, 2]);
|
||||
expect(result, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test('failed durable apply stops pagination', () async {
|
||||
var calls = 0;
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return response(page(1, more: true, changes: [change(1)]));
|
||||
}),
|
||||
);
|
||||
await expectLater(
|
||||
api.synchronize(
|
||||
accessToken: () async => 'token',
|
||||
cursor: 0,
|
||||
deviceId: 'device',
|
||||
changes: [],
|
||||
applyPage: (_, _) async => throw StateError('disk full'),
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('invalid response cursors, sequence and shape are rejected', () async {
|
||||
final invalidPages = <Object>[
|
||||
page(0, more: true),
|
||||
page(1),
|
||||
page(2, changes: [change(1)]),
|
||||
page(1, changes: [change(1), change(1)]),
|
||||
{...page(0), 'hasMore': 'yes'},
|
||||
{...page(0), 'cursor': 0.5},
|
||||
{...page(0), 'serverTime': 'not-a-date'},
|
||||
[],
|
||||
];
|
||||
for (final invalid in invalidPages) {
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient((_) async => response(invalid)),
|
||||
);
|
||||
await expectLater(pull(api), throwsFormatException, reason: '$invalid');
|
||||
}
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient((_) async => response(page(0))),
|
||||
);
|
||||
await expectLater(pull(api, cursor: 1), throwsFormatException);
|
||||
});
|
||||
|
||||
test(
|
||||
'HTTP errors preserve structured status and discard raw proxy bodies',
|
||||
() async {
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient(
|
||||
(_) async => response({
|
||||
'error': {'code': 'invalid_token', 'message': 'Expired'},
|
||||
}, status: 401),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
pull(api),
|
||||
throwsA(
|
||||
isA<SyncApiException>()
|
||||
.having((e) => e.statusCode, 'status', 401)
|
||||
.having((e) => e.code, 'code', 'invalid_token'),
|
||||
),
|
||||
);
|
||||
final proxy = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient(
|
||||
(_) async =>
|
||||
http.Response('<html>private proxy diagnostics</html>', 502),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
pull(proxy),
|
||||
throwsA(
|
||||
isA<SyncApiException>().having(
|
||||
(e) => e.toString().contains('private'),
|
||||
'no raw response body',
|
||||
isFalse,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('malformed sessions and unsafe server addresses are rejected', () async {
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient((_) async => response({'accessToken': 'token'})),
|
||||
);
|
||||
await expectLater(api.login('a@b.com', 'password'), throwsFormatException);
|
||||
for (final url in [
|
||||
'http://example.com',
|
||||
'https://user:pass@example.com',
|
||||
'https://example.com?token=x',
|
||||
'file:///tmp/test',
|
||||
]) {
|
||||
expect(() => SyncApi(serverUrl: url), throwsArgumentError);
|
||||
}
|
||||
expect(
|
||||
SyncApi(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
allowInsecureHttp: true,
|
||||
).baseUri.scheme,
|
||||
'http',
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid outgoing changes fail before any HTTP call', () async {
|
||||
var calls = 0;
|
||||
final api = SyncApi(
|
||||
serverUrl: 'https://example.com',
|
||||
client: MockClient((_) async {
|
||||
calls++;
|
||||
return response(page(0));
|
||||
}),
|
||||
);
|
||||
final invalid = change(1)..['entity'] = 'unknown';
|
||||
await expectLater(
|
||||
api.sync(
|
||||
accessToken: 'token',
|
||||
cursor: 0,
|
||||
deviceId: 'device',
|
||||
changes: [invalid],
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(calls, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import 'dart:io';
|
||||
import 'package:balance_flutter/domain/finance.dart';
|
||||
import 'package:balance_flutter/services/app_store.dart';
|
||||
import 'package:balance_flutter/services/storage.dart';
|
||||
import 'package:balance_flutter/ui/app.dart';
|
||||
import 'package:balance_flutter/ui/editors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const screenSizes = [Size(390, 844), Size(1280, 900), Size(320, 568)];
|
||||
const pageIcons = [
|
||||
Icons.space_dashboard_outlined,
|
||||
Icons.receipt_long_outlined,
|
||||
Icons.insights_outlined,
|
||||
Icons.savings_outlined,
|
||||
Icons.settings_outlined,
|
||||
];
|
||||
|
||||
void mockStorage() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
}
|
||||
|
||||
Future<void> loadReadableFont() async {
|
||||
// Use the exact offline app fonts; Ahem changes Cyrillic button widths.
|
||||
final loader = FontLoader('BalanceRoboto');
|
||||
for (final weight in ['Regular', 'Medium', 'Bold']) {
|
||||
loader.addFont(rootBundle.load('assets/fonts/Roboto-$weight.ttf'));
|
||||
}
|
||||
await loader.load();
|
||||
await (FontLoader(
|
||||
'MaterialIcons',
|
||||
)..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf'))).load();
|
||||
}
|
||||
|
||||
Future<AppStore> createStore({bool seeded = true}) async {
|
||||
final store = AppStore(storage: MemorySnapshotStorage());
|
||||
await store.initialize();
|
||||
if (seeded) {
|
||||
final now = DateTime.now();
|
||||
await store.saveCategory(
|
||||
CategoryRecord(
|
||||
id: 'custom',
|
||||
name: 'Дом и повседневные покупки',
|
||||
icon: 'house.fill',
|
||||
colorName: 'teal',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await store.saveTransaction(
|
||||
TransactionRecord(
|
||||
id: 'tx-$i',
|
||||
amountMinor: i == 0 ? 25000000 : 12345 + i * 1000,
|
||||
kindRawValue: i == 0 ? 'income' : 'expense',
|
||||
date: now,
|
||||
categoryName: i == 0 ? 'Зарплата' : 'Дом и повседневные покупки',
|
||||
categoryIcon: i == 0 ? 'banknote.fill' : 'house.fill',
|
||||
categoryColorName: 'teal',
|
||||
note: 'Проверка длинного описания операции',
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
await store.saveBudget(
|
||||
BudgetRecord(
|
||||
id: 'budget',
|
||||
categoryName: 'Дом и повседневные покупки',
|
||||
categoryIcon: 'house.fill',
|
||||
limitMinor: 500000,
|
||||
monthStart: DateTime(now.year, now.month),
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
Future<void> boot(
|
||||
WidgetTester tester,
|
||||
AppStore store,
|
||||
Size size, {
|
||||
Key? boundaryKey,
|
||||
}) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = size;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(store.dispose);
|
||||
await tester.pumpWidget(
|
||||
boundaryKey == null
|
||||
? BalanceApp(store: store)
|
||||
: RepaintBoundary(
|
||||
key: boundaryKey,
|
||||
child: BalanceApp(store: store),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> navigate(WidgetTester tester, int page) async {
|
||||
final navigation = find.byType(NavigationBar).evaluate().isNotEmpty
|
||||
? find.byType(NavigationBar)
|
||||
: find.byType(NavigationRail);
|
||||
await tester.tap(
|
||||
find
|
||||
.descendant(of: navigation, matching: find.byIcon(pageIcons[page]))
|
||||
.first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> reveal(WidgetTester tester, Finder finder) async {
|
||||
await Scrollable.ensureVisible(tester.element(finder), alignment: 0.5);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder field(String label) =>
|
||||
find.ancestor(of: find.text(label), matching: find.byType(TextFormField));
|
||||
|
||||
Future<void> saveDialog(WidgetTester tester) async {
|
||||
tester.testTextInput.hide();
|
||||
await tester.pumpAndSettle();
|
||||
final save = find.widgetWithText(FilledButton, 'Сохранить');
|
||||
await reveal(tester, save);
|
||||
await tester.tap(save);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.runAsync(() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(loadReadableFont);
|
||||
setUp(mockStorage);
|
||||
testWidgets('navigation and scrolling with large text on a small phone', (
|
||||
tester,
|
||||
) async {
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
final store = (await tester.runAsync(() => createStore()))!;
|
||||
await boot(tester, store, const Size(320, 568));
|
||||
for (var page = 0; page < 5; page++) {
|
||||
await navigate(tester, page);
|
||||
expect(tester.takeException(), isNull, reason: 'large text page=$page');
|
||||
for (var step = 0; step < 6; step++) {
|
||||
await tester.drag(find.byType(ListView).first, const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: 'large text page=$page scroll=$step',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('negative balance adjustment preserves analytics', (
|
||||
tester,
|
||||
) async {
|
||||
final store = (await tester.runAsync(() => createStore()))!;
|
||||
final now = DateTime.now();
|
||||
final start = DateTime(now.year, now.month);
|
||||
final end = DateTime(now.year, now.month + 1);
|
||||
final before = FinanceMath.summary(store.transactions, start, end);
|
||||
final spending = FinanceMath.spending(
|
||||
store.transactions,
|
||||
start,
|
||||
end,
|
||||
).map((e) => e.amountMinor).toList();
|
||||
await boot(tester, store, const Size(390, 844));
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Изменить'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Фактический баланс, RUB'), '-150,25');
|
||||
await saveDialog(tester);
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
expect(FinanceMath.totalBalance(store.transactions), -15025);
|
||||
expect(
|
||||
store.transactions.where((t) => t.isBalanceAdjustment),
|
||||
hasLength(1),
|
||||
);
|
||||
final after = FinanceMath.summary(store.transactions, start, end);
|
||||
expect(after.incomeMinor, before.incomeMinor);
|
||||
expect(after.expensesMinor, before.expensesMinor);
|
||||
expect(
|
||||
FinanceMath.spending(
|
||||
store.transactions,
|
||||
start,
|
||||
end,
|
||||
).map((e) => e.amountMinor).toList(),
|
||||
spending,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final desktop in [true, false]) {
|
||||
testWidgets(
|
||||
'CSV export reaches ${desktop ? 'save selector' : 'share sheet'}',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = desktop
|
||||
? TargetPlatform.linux
|
||||
: TargetPlatform.android;
|
||||
try {
|
||||
final store = (await tester.runAsync(
|
||||
() => createStore(seeded: false),
|
||||
))!;
|
||||
await tester.runAsync(
|
||||
() => store.saveTransaction(
|
||||
TransactionRecord(
|
||||
id: 'csv',
|
||||
amountMinor: 12345,
|
||||
date: DateTime(2026, 10, 28),
|
||||
categoryName: 'Покупки, дом',
|
||||
note: 'Текст "в кавычках"\nНовая строка',
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final expected = FinanceMath.exportCsv(store.transactions);
|
||||
final directory = Directory('/tmp/balance-export')
|
||||
..createSync(recursive: true);
|
||||
final destination = File('${directory.path}/desktop.csv');
|
||||
if (destination.existsSync()) destination.deleteSync();
|
||||
Map<Object?, Object?>? selectorArguments;
|
||||
String? sharedCsv;
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/file_selector'),
|
||||
(call) async {
|
||||
expect(call.method, 'getSavePath');
|
||||
selectorArguments = call.arguments as Map;
|
||||
return destination.path;
|
||||
},
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(_) async => directory.path,
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('dev.fluttercommunity.plus/share'),
|
||||
(call) async {
|
||||
expect(call.method, 'share');
|
||||
final args = call.arguments as Map;
|
||||
expect(args['mimeTypes'], ['text/csv']);
|
||||
final path = (args['paths'] as List).single as String;
|
||||
expect(path.endsWith('balance-transactions.csv'), isTrue);
|
||||
sharedCsv = await File(path).readAsString();
|
||||
return 'test-share-target';
|
||||
},
|
||||
);
|
||||
await boot(tester, store, const Size(390, 844));
|
||||
await navigate(tester, 4);
|
||||
final export = find.text('Экспорт истории CSV');
|
||||
await tester.scrollUntilVisible(
|
||||
export,
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await reveal(tester, export);
|
||||
await tester.tap(export);
|
||||
await tester.pumpAndSettle();
|
||||
// Drain real filesystem work and test-zone channel continuations.
|
||||
for (var i = 0; i < 50; i++) {
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
if (desktop
|
||||
? destination.existsSync() &&
|
||||
destination.readAsStringSync() == expected
|
||||
: sharedCsv != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
if (desktop) {
|
||||
expect(
|
||||
selectorArguments?['suggestedName'],
|
||||
'balance-transactions.csv',
|
||||
);
|
||||
expect(destination.readAsStringSync(), expected);
|
||||
} else {
|
||||
expect(sharedCsv, expected);
|
||||
}
|
||||
expect(expected, contains('"Текст ""в кавычках""\nНовая строка"'));
|
||||
expect(tester.takeException(), isNull);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
for (final size in screenSizes) {
|
||||
testWidgets('account and server form validation at $size', (tester) async {
|
||||
final store = (await tester.runAsync(() => createStore(seeded: false)))!;
|
||||
await tester.runAsync(() => store.setServerUrl('https://example.com'));
|
||||
await boot(tester, store, size);
|
||||
await navigate(tester, 4);
|
||||
for (final action in ['Войти', 'Регистрация']) {
|
||||
final button = find.widgetWithText(
|
||||
action == 'Войти' ? FilledButton : TextButton,
|
||||
action,
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
button,
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await reveal(tester, button);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
final submit = find
|
||||
.widgetWithText(
|
||||
FilledButton,
|
||||
action == 'Войти' ? 'Войти' : 'Зарегистрироваться',
|
||||
)
|
||||
.last;
|
||||
await reveal(tester, submit);
|
||||
await tester.tap(submit);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Введите email'), findsOneWidget);
|
||||
expect(store.session, isNull);
|
||||
expect(tester.takeException(), isNull);
|
||||
await reveal(tester, find.text('Отмена'));
|
||||
await tester.tap(find.text('Отмена'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
final server = find.widgetWithText(OutlinedButton, 'Адрес сервера');
|
||||
await reveal(tester, server);
|
||||
await tester.tap(server);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('https://balance.example.com'), 'invalid');
|
||||
await saveDialog(tester);
|
||||
expect(find.text('Введите полный адрес сервера'), findsOneWidget);
|
||||
expect(store.serverUrl, 'https://example.com');
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('Отмена'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
for (final seeded in [false, true]) {
|
||||
testWidgets('all pages scroll without overflow $size seeded=$seeded', (
|
||||
tester,
|
||||
) async {
|
||||
final store = (await tester.runAsync(
|
||||
() => createStore(seeded: seeded),
|
||||
))!;
|
||||
await boot(tester, store, size);
|
||||
for (var page = 0; page < 5; page++) {
|
||||
await navigate(tester, page);
|
||||
expect(tester.takeException(), isNull, reason: 'page=$page at $size');
|
||||
// Exercise lazy children and the lower settings/categories sections.
|
||||
for (var step = 0; step < 8; step++) {
|
||||
await tester.drag(
|
||||
find.byType(ListView).first,
|
||||
const Offset(0, -350),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: 'page=$page scroll=$step at $size',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
testWidgets('all editors are usable at $size', (tester) async {
|
||||
final store = (await tester.runAsync(() => createStore(seeded: false)))!;
|
||||
await boot(tester, store, size);
|
||||
final context = tester.element(find.byType(BalanceShell));
|
||||
final widgets = <Widget>[
|
||||
const TransactionEditor(categories: []),
|
||||
const CategoryEditor(categories: []),
|
||||
BudgetEditor(
|
||||
categories: BuiltInCategories.expense,
|
||||
month: DateTime.now(),
|
||||
),
|
||||
];
|
||||
for (final editor in widgets) {
|
||||
editDialog<void>(context, editor);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
await saveDialog(tester);
|
||||
expect(
|
||||
find.byType(Form),
|
||||
findsOneWidget,
|
||||
); // Invalid empty form stays open.
|
||||
expect(tester.takeException(), isNull);
|
||||
await reveal(tester, find.text('Отмена'));
|
||||
await tester.tap(find.text('Отмена'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('transaction add, edit and delete through the interface', (
|
||||
tester,
|
||||
) async {
|
||||
final store = (await tester.runAsync(() => createStore(seeded: false)))!;
|
||||
await boot(tester, store, screenSizes.first);
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Сумма'), '123,45');
|
||||
await tester.enterText(field('Заметка'), 'Покупка для теста');
|
||||
await saveDialog(tester);
|
||||
expect(store.transactions.single.amountMinor, 12345);
|
||||
expect(store.transactions.single.note, 'Покупка для теста');
|
||||
final id = store.transactions.single.id;
|
||||
await navigate(tester, 1);
|
||||
await reveal(tester, find.textContaining('Покупка для теста'));
|
||||
await tester.tap(find.textContaining('Покупка для теста'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Сумма'), '250.50');
|
||||
await saveDialog(tester);
|
||||
expect(store.transactions.single.id, id);
|
||||
expect(store.transactions.single.amountMinor, 25050);
|
||||
await reveal(tester, find.byTooltip('Действия с операцией'));
|
||||
await tester.tap(find.byTooltip('Действия с операцией'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Удалить'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Удалить'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(store.transactions, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('custom category and monthly budget CRUD through the interface', (
|
||||
tester,
|
||||
) async {
|
||||
final store = (await tester.runAsync(() => createStore(seeded: false)))!;
|
||||
await boot(tester, store, screenSizes.first);
|
||||
await navigate(tester, 4);
|
||||
await tester.scrollUntilVisible(
|
||||
find.widgetWithText(FilledButton, 'Добавить'),
|
||||
250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await reveal(tester, find.widgetWithText(FilledButton, 'Добавить'));
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Добавить'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Название'), 'Мой сад');
|
||||
await saveDialog(tester);
|
||||
expect(store.categories.single.name, 'Мой сад');
|
||||
final id = store.categories.single.id;
|
||||
await reveal(tester, find.text('Мой сад'));
|
||||
await tester.tap(find.text('Мой сад'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Название'), 'Мой дом');
|
||||
await saveDialog(tester);
|
||||
expect(store.categories.single.id, id);
|
||||
expect(store.categories.single.name, 'Мой дом');
|
||||
await navigate(tester, 3);
|
||||
await tester.tap(find.text('Добавить лимит'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Мой дом').last,
|
||||
180,
|
||||
scrollable: find.byType(Scrollable).last,
|
||||
);
|
||||
await tester.tap(find.text('Мой дом').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Месячный лимит'), '5000');
|
||||
await saveDialog(tester);
|
||||
expect(store.budgets.single.categoryName, 'Мой дом');
|
||||
expect(store.budgets.single.limitMinor, 500000);
|
||||
await reveal(tester, find.byTooltip('Изменить лимит'));
|
||||
await tester.tap(find.byTooltip('Изменить лимит'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Месячный лимит'), '7500');
|
||||
await saveDialog(tester);
|
||||
expect(store.budgets.single.limitMinor, 750000);
|
||||
await reveal(tester, find.byTooltip('Удалить лимит'));
|
||||
await tester.tap(find.byTooltip('Удалить лимит'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Удалить'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(store.budgets, isEmpty);
|
||||
await navigate(tester, 4);
|
||||
await tester.scrollUntilVisible(
|
||||
find.byTooltip('Удалить категорию'),
|
||||
250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await reveal(tester, find.byTooltip('Удалить категорию'));
|
||||
await tester.tap(find.byTooltip('Удалить категорию'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Удалить'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(store.categories, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user