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

This commit is contained in:
wt committed 2026-10-28 15:37:32 +07:00
1 parent a715eca947
commit 2c278044bb
214 files changed
+13411 -2290

No files matched your search

+520
View File
@@ -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);
}
}
+240
View File
@@ -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();
});
}
+84
View File
@@ -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();
}
},
);
}
}
+255
View File
@@ -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);
});
}
+502
View File
@@ -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);
});
}