feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:please_pay_me/core/config/app_config.dart';
import 'package:please_pay_me/core/storage/session_storage.dart';
import 'package:please_pay_me/features/auth/login_screen.dart';
import 'package:please_pay_me/features/auth/session_controller.dart';
import 'package:please_pay_me/features/auth/telegram_login.dart';
import 'package:please_pay_me/features/auth/yandex_login.dart';
import 'package:please_pay_me/theme/theme.dart';
import 'package:provider/provider.dart';
import 'support/test_setup.dart';
void main() {
setUpAll(setUpTestEnvironment);
group('resolveApiBaseUrl', () {
test('an explicitly configured API address wins', () {
expect(
resolveApiBaseUrl(
cabinetUrl: 'https://cabinet.example.com',
configuredApiBaseUrl: 'https://api.example.com/',
),
'https://api.example.com',
);
});
test('falls back to the cabinet origin, dropping the path', () {
expect(
resolveApiBaseUrl(cabinetUrl: 'https://ppm.example.com/cabinet/budgets'),
'https://ppm.example.com',
);
});
test('keeps a non-default port', () {
expect(
resolveApiBaseUrl(cabinetUrl: 'http://192.168.0.10:8080/'),
'http://192.168.0.10:8080',
);
});
test('assumes https when the scheme is omitted', () {
expect(resolveApiBaseUrl(cabinetUrl: 'ppm.example.com/'), 'https://ppm.example.com');
});
});
group('resolveCabinetLoginUri', () {
test('opens /login on the cabinet origin', () {
expect(
resolveCabinetLoginUri('https://ppm.example.com'),
Uri.parse('https://ppm.example.com/login'),
);
});
test('keeps an explicit path', () {
expect(
resolveCabinetLoginUri('https://ppm.example.com/cabinet'),
Uri.parse('https://ppm.example.com/cabinet'),
);
});
});
group('navigation helpers', () {
test('detects Telegram deep links', () {
expect(isExternalAuthScheme(Uri.parse('tg://resolve?domain=bot')), isTrue);
expect(isExternalAuthScheme(Uri.parse('https://oauth.telegram.org/auth')), isFalse);
});
test('detects Telegram OAuth hosts', () {
expect(isTelegramOAuthHost('oauth.telegram.org'), isTrue);
expect(isTelegramOAuthHost('ppm.example.com'), isFalse);
});
});
group('telegram auth bridge', () {
test('probe script reads the key the web cabinet writes', () {
// Must stay in sync with SESSION_KEY in web/src/api.ts.
expect(telegramTokenProbeJs, contains("getItem('ppm_session_jwt')"));
expect(telegramTokenProbeJs, contains('$telegramAuthChannel.postMessage'));
});
test('parses the posted payload', () {
expect(parseTelegramAuthMessage(jsonEncode({'token': 'jwt'})), 'jwt');
});
test('ignores empty and malformed payloads', () {
expect(parseTelegramAuthMessage(jsonEncode({'token': ''})), isNull);
expect(parseTelegramAuthMessage('not json'), isNull);
});
});
group('yandex oauth helpers', () {
test('builds the authorize URL', () {
final uri = yandexAuthorizeUri(
clientId: 'abc',
redirectUri: 'https://please-pay-me.ru/',
);
expect(uri.host, 'oauth.yandex.ru');
expect(uri.queryParameters['client_id'], 'abc');
expect(uri.queryParameters['redirect_uri'], 'https://please-pay-me.ru/');
expect(uri.queryParameters['response_type'], 'code');
});
test('reads a successful cabinet callback on the origin', () {
final callback = parseYandexCallback(
Uri.parse('https://please-pay-me.ru/?code=from-yandex'),
redirectUri: 'https://please-pay-me.ru/',
);
expect(callback?.code, 'from-yandex');
expect(callback?.error, isNull);
});
test('reads a denied callback and ignores other hosts', () {
expect(
parseYandexCallback(
Uri.parse('https://please-pay-me.ru/?error=access_denied'),
redirectUri: 'https://please-pay-me.ru/',
)?.error,
'access_denied',
);
expect(
parseYandexCallback(
Uri.parse('https://oauth.yandex.ru/authorize?code=nope'),
redirectUri: 'https://please-pay-me.ru/',
),
isNull,
);
});
test('derives the origin slash that Yandex registered', () {
expect(
cabinetYandexRedirectUri('https://please-pay-me.ru/budgets'),
'https://please-pay-me.ru/',
);
expect(
cabinetYandexRedirectUri(
'https://please-pay-me.ru',
configured: 'https://please-pay-me.ru/',
),
'https://please-pay-me.ru/',
);
});
});
group('SessionController.signInWithToken', () {
SessionController controllerWith(http.Client client, SessionStorage storage) {
return SessionController(
config: AppConfig.demo,
storage: storage,
httpClient: client,
);
}
test('stores the session after the token is validated', () async {
late Uri requestedUri;
String? authHeader;
final client = MockClient((request) async {
requestedUri = request.url;
authHeader = request.headers['Authorization'];
return http.Response(
jsonEncode({'user_id': 7, 'username': 'vlad', 'first_name': 'Владимир'}),
200,
headers: {'content-type': 'application/json'},
);
});
final storage = InMemorySessionStorage();
final session = controllerWith(client, storage);
final ok = await session.signInWithToken(
baseUrl: 'https://ppm.example.com/',
token: 'jwt-token',
);
expect(ok, isTrue);
expect(session.status, SessionStatus.signedIn);
expect(session.user?.username, 'vlad');
expect(requestedUri.toString(), 'https://ppm.example.com/api/me');
expect(authHeader, 'Bearer jwt-token');
expect((await storage.readAll())['token'], 'jwt-token');
});
test('a rejected token leaves the user signed out with a message', () async {
final client = MockClient((_) async => http.Response('', 401));
final session = controllerWith(client, InMemorySessionStorage());
final ok = await session.signInWithToken(
baseUrl: 'https://ppm.example.com',
token: 'stale',
);
expect(ok, isFalse);
expect(session.status, SessionStatus.signedOut);
expect(session.lastError, isNotNull);
});
});
testWidgets('Yandex login exchanges the code on the API', (tester) async {
String? seenClientId;
String? seenRedirect;
final client = MockClient((request) async {
if (request.url.path.endsWith('/api/auth/providers')) {
return http.Response(
jsonEncode({
'yandex': {'enabled': true, 'client_id': 'ya-client'},
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response(
jsonEncode({'user_id': 7, 'username': 'ya-user'}),
200,
headers: {'content-type': 'application/json'},
);
});
final session = SessionController(
config: const AppConfig(
apiBaseUrl: 'https://ppm.example.com',
webCabinetUrl: 'https://ppm.example.com',
demoMode: false,
),
storage: InMemorySessionStorage(),
httpClient: client,
);
await tester.pumpWidget(
ChangeNotifierProvider<SessionController>.value(
value: session,
child: CupertinoApp(
theme: buildLightTheme(),
home: LoginScreen(
launchTelegramLogin: (_, __) async => null,
launchYandexLogin: (_, {required clientId, required redirectUri}) async {
seenClientId = clientId;
seenRedirect = redirectUri;
return 'jwt-from-yandex';
},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
await tester.ensureVisible(find.byKey(const Key('legal-offer-check')));
await tester.tap(find.byKey(const Key('legal-offer-check')));
await tester.pump();
await tester.ensureVisible(find.byKey(const Key('legal-consent-check')));
await tester.tap(find.byKey(const Key('legal-consent-check')));
await tester.pump();
await tester.tap(find.text('Войти через Яндекс'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(seenClientId, 'ya-client');
expect(seenRedirect, 'https://ppm.example.com/');
expect(session.status, SessionStatus.signedIn);
expect(session.user?.username, 'ya-user');
});
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:please_pay_me/core/config/app_config.dart';
import 'package:please_pay_me/core/config/env_file.dart';
void main() {
group('parseEnvFile', () {
test('reads keys, comments and quoted values', () {
const source = '''
# comment
PPM_API_BASE_URL=https://please-pay-me.ru/
PPM_WEB_URL="https://please-pay-me.ru"
PPM_DEMO=false
EMPTY=
''';
final values = parseEnvFile(source);
expect(values['PPM_API_BASE_URL'], 'https://please-pay-me.ru/');
expect(values['PPM_WEB_URL'], 'https://please-pay-me.ru');
expect(values['PPM_DEMO'], 'false');
expect(values.containsKey('EMPTY'), isTrue);
expect(values['EMPTY'], '');
});
});
group('AppConfig.fromMap', () {
test('strips a trailing slash and defaults the cabinet URL', () {
final config = AppConfig.fromMap({
'PPM_API_BASE_URL': 'https://please-pay-me.ru/',
});
expect(config.apiBaseUrl, 'https://please-pay-me.ru');
expect(config.webCabinetUrl, 'https://please-pay-me.ru');
expect(config.demoMode, isFalse);
});
test('empty map uses the production cabinet', () {
final config = AppConfig.fromMap(const {});
expect(config.apiBaseUrl, AppConfig.productionOrigin);
expect(config.webCabinetUrl, AppConfig.productionOrigin);
expect(config.demoMode, isFalse);
});
test('PPM_DEMO=true forces demo even when a URL is set', () {
final config = AppConfig.fromMap({
'PPM_API_BASE_URL': 'https://please-pay-me.ru',
'PPM_DEMO': 'true',
});
expect(config.demoMode, isTrue);
expect(config.apiBaseUrl, 'https://please-pay-me.ru');
});
});
test('.env.example stays in sync with the parser', () {
final text = File('.env.example').readAsStringSync();
final values = parseEnvFile(text);
final config = AppConfig.fromMap(values);
expect(values.containsKey('PPM_API_BASE_URL'), isTrue);
expect(config.apiBaseUrl, isNotEmpty);
expect(config.demoMode, isFalse);
});
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:please_pay_me/core/config/app_config.dart';
import 'package:please_pay_me/core/storage/session_storage.dart';
import 'package:please_pay_me/data/demo/demo_backend.dart';
import 'package:please_pay_me/features/auth/session_controller.dart';
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
import 'package:please_pay_me/features/journal/journal_controller.dart';
import 'package:please_pay_me/features/work/jobs_controller.dart';
void main() {
late DemoBackend backend;
setUp(() => backend = DemoBackend(today: DateTime(2026, 9, 19)));
group('BudgetsController', () {
test('loads budgets and exposes the selected one', () async {
final controller = BudgetsController(
budgets: backend.budgets,
expenses: backend.expenses,
);
await controller.load();
expect(controller.items, hasLength(2));
expect(controller.selected?.budget.name, 'До аванса');
expect(controller.hasBudgets, isTrue);
});
test('adding an expense lowers the remaining amount', () async {
final controller = BudgetsController(
budgets: backend.budgets,
expenses: backend.expenses,
);
await controller.load();
final before = controller.selected!.remaining;
final error = await controller.addExpense(amount: 500, note: 'Обед');
expect(error, isNull);
expect(controller.selected!.remaining, before - 500);
expect(controller.isMutating, isFalse);
});
test('undo removes the newest expense of the selected budget', () async {
final controller = BudgetsController(
budgets: backend.budgets,
expenses: backend.expenses,
);
await controller.load();
await controller.addExpense(amount: 777);
final withExtra = controller.selected!.totalSpent;
await controller.undoLastExpense();
expect(controller.selected!.totalSpent, withExtra - 777);
});
test('select switches the current envelope', () async {
final controller = BudgetsController(
budgets: backend.budgets,
expenses: backend.expenses,
);
await controller.load();
await controller.select(2);
expect(controller.selected?.budget.id, 2);
});
test('surfaces backend errors without crashing', () async {
final empty = DemoBackend.empty();
final controller = BudgetsController(
budgets: empty.budgets,
expenses: empty.expenses,
);
await controller.load();
final error = await controller.addExpense(amount: 100);
expect(error, 'Сначала создайте бюджет');
expect(controller.hasBudgets, isFalse);
});
});
group('JournalController', () {
test('groups operations by day, newest first', () async {
final controller = JournalController(expenses: backend.expenses);
await controller.load();
final groups = controller.groups;
expect(groups.first.day, DateTime(2026, 9, 19));
expect(groups.first.items, hasLength(2));
expect(groups.first.total, 2090);
expect(groups.map((g) => g.day), isNot(contains(DateTime(2026, 8, 30))));
});
test('scope "all" includes other budgets', () async {
final controller = JournalController(expenses: backend.expenses);
await controller.load();
final currentCount = controller.items.length;
await controller.setScope(JournalScope.all);
expect(controller.items.length, greaterThan(currentCount));
expect(controller.scope, JournalScope.all);
});
test('loadMore appends the next page', () async {
final controller = JournalController(expenses: backend.expenses, pageSize: 2);
await controller.load();
expect(controller.items, hasLength(2));
expect(controller.hasMore, isTrue);
await controller.loadMore();
expect(controller.items, hasLength(4));
expect(controller.hasMore, isFalse);
});
});
group('JobsController', () {
test('nextPay picks the closest payday', () async {
final controller = JobsController(jobs: backend.jobs);
await controller.load();
expect(controller.items, hasLength(1));
expect(controller.nextPay?.date, DateTime(2026, 9, 27));
});
});
group('SessionController', () {
test('restores a stored session', () async {
final session = SessionController(
config: AppConfig.demo,
storage: InMemorySessionStorage({
'token': 'jwt',
'baseUrl': 'https://ppm.example.com',
'user': '{"user_id":7,"username":"vlad"}',
}),
);
await session.restore();
expect(session.status, SessionStatus.signedIn);
expect(session.user?.userId, 7);
expect(session.isDemo, isFalse);
});
test('without a stored token the app asks to sign in', () async {
final session = SessionController(
config: AppConfig.demo,
storage: InMemorySessionStorage(),
);
await session.restore();
expect(session.status, SessionStatus.signedOut);
});
test('demo mode serves repositories from the in-memory backend', () async {
final session = SessionController(
config: AppConfig.demo,
storage: InMemorySessionStorage(),
demoBackend: backend,
)..startDemo();
expect(session.status, SessionStatus.signedIn);
expect(await session.budgets.list(), hasLength(2));
expect(session.sessionKey, contains('demo'));
});
test('sign out clears the session', () async {
final storage = InMemorySessionStorage({
'token': 'jwt',
'baseUrl': 'https://ppm.example.com',
'user': '{"user_id":7}',
});
final session = SessionController(config: AppConfig.demo, storage: storage);
await session.restore();
await session.signOut();
expect(session.status, SessionStatus.signedOut);
expect(await storage.readAll(), isEmpty);
});
});
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:please_pay_me/core/format/formatters.dart';
import 'support/test_setup.dart';
void main() {
setUpAll(setUpTestEnvironment);
test('money uses Russian grouping and the ruble sign', () {
expect(formatMoney(1234.5), '1 234,50 ₽');
expect(formatMoney(250, compact: true), '250 ₽');
});
test('expenses are rendered as a deduction', () {
expect(formatSignedMoney(250), '250,00 ₽');
expect(formatSignedMoney(-250), '+250,00 ₽');
});
test('plural follows Russian rules', () {
expect(plural(1, 'день', 'дня', 'дней'), '1 день');
expect(plural(3, 'день', 'дня', 'дней'), '3 дня');
expect(plural(11, 'день', 'дня', 'дней'), '11 дней');
expect(plural(21, 'день', 'дня', 'дней'), '21 день');
});
test('relative day switches to a date beyond yesterday', () {
final now = DateTime(2026, 9, 19);
expect(formatRelativeDay(now, now: now), 'Сегодня');
expect(formatRelativeDay(DateTime(2026, 9, 18), now: now), 'Вчера');
expect(formatRelativeDay(DateTime(2026, 9, 12), now: now), '12 сентября');
});
test('days left is humanized', () {
expect(formatDaysLeft(0), 'период завершён');
expect(formatDaysLeft(5), 'осталось 5 дней');
});
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:please_pay_me/core/config/app_config.dart';
import 'package:please_pay_me/core/legal/legal_links.dart';
void main() {
test('legal URLs point at the cabinet origin', () {
expect(
LegalLinks.resolve('https://please-pay-me.ru/', LegalLinks.privacy),
Uri.parse('https://please-pay-me.ru/legal/privacy'),
);
});
test('empty cabinet falls back to production', () {
expect(
LegalLinks.resolve('', LegalLinks.offer),
Uri.parse('${AppConfig.productionOrigin}/legal/offer'),
);
});
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:please_pay_me/data/models/budget.dart';
import 'package:please_pay_me/data/models/expense.dart';
import 'package:please_pay_me/data/models/job.dart';
import 'package:please_pay_me/data/models/json.dart';
void main() {
group('json helpers', () {
test('coerce numbers coming as int, double or string', () {
expect(asDouble(10), 10.0);
expect(asDouble(10.5), 10.5);
expect(asDouble('10,5'), 10.5);
expect(asDouble(null, fallback: -1), -1);
expect(asInt('42'), 42);
});
test('asDate keeps the calendar day and drops time', () {
expect(asDate('2026-09-19'), DateTime(2026, 9, 19));
expect(asDate('2026-09-19T23:45:00Z'), DateTime(2026, 9, 19));
});
test('formatIsoDate pads month and day', () {
expect(formatIsoDate(DateTime(2026, 1, 5)), '2026-01-05');
});
});
group('BudgetStatus', () {
BudgetStatus parse(Map<String, dynamic> overrides) {
return BudgetStatus.fromJson({
'budget': {
'id': 1,
'user_id': 7,
'name': 'До аванса',
'total_amount': 42000,
'start_date': '2026-09-13',
'end_date': '2026-09-27',
'currency': 'RUB',
'is_active': true,
},
'today': '2026-09-19',
'days_left': 9,
'total_spent': 21000,
'remaining': 21000,
'daily_limit': 2333.33,
'spent_today': 1200,
'remaining_today': 1133.33,
'is_over_daily': false,
'is_over_budget': false,
'is_expired': false,
'selected': true,
...overrides,
});
}
test('parses nested budget', () {
final status = parse({});
expect(status.budget.name, 'До аванса');
expect(status.budget.endDate, DateTime(2026, 9, 27));
expect(status.selected, isTrue);
});
test('spentProgress is a clamped share of the total', () {
expect(parse({}).spentProgress, closeTo(0.5, 0.001));
expect(parse({'total_spent': 50000}).spentProgress, 1.0);
});
test('dailyProgress falls back to full bar when the limit is zero', () {
expect(parse({'daily_limit': 0, 'spent_today': 500}).dailyProgress, 1.0);
expect(parse({'daily_limit': 0, 'spent_today': 0}).dailyProgress, 0.0);
});
});
group('ExpensesPage', () {
test('reports pagination state', () {
final page = ExpensesPage.fromJson({
'page': 1,
'total_pages': 3,
'total_count': 55,
'page_size': 20,
'total_sum': 12345.5,
'budget_id': 4,
'items': [
{'id': 1, 'budget_id': 4, 'amount': 250, 'note': 'Кофе', 'spent_at': '2026-09-19'},
{'id': 2, 'budget_id': 4, 'amount': 100.5, 'note': null, 'spent_at': '2026-09-18'},
],
});
expect(page.hasMore, isTrue);
expect(page.items, hasLength(2));
expect(page.items.last.note, isNull);
expect(page.totalSum, 12345.5);
});
});
group('Job', () {
test('parses pay days and the weekend policy', () {
final job = Job.fromJson({
'id': 1,
'user_id': 7,
'name': 'Основная',
'salary_amount': 180000,
'currency': 'RUB',
'pay_days': [5, 20],
'first_pay_percent': 40,
'weekend_policy': 'after_weekend',
'is_active': true,
'next_pays': [
{'date': '2026-09-20', 'scheduled_day': 20, 'percent': 60, 'amount': 108000},
],
});
expect(job.payDays, [5, 20]);
expect(job.weekendPolicy, WeekendPolicy.afterWeekend);
expect(job.nextPay?.amount, 108000);
});
test('unknown weekend policy degrades to the default', () {
expect(WeekendPolicy.fromWire('nonsense'), WeekendPolicy.beforeWeekend);
});
});
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/date_symbol_data_local.dart';
/// Shared bootstrap: no font downloads, Russian date symbols available.
Future<void> setUpTestEnvironment() async {
TestWidgetsFlutterBinding.ensureInitialized();
GoogleFonts.config.allowRuntimeFetching = false;
await initializeDateFormatting('ru');
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:please_pay_me/theme/theme.dart';
void main() {
group('ThemePreference', () {
test('parse falls back to system', () {
expect(ThemePreference.parse(null), ThemePreference.system);
expect(ThemePreference.parse('nope'), ThemePreference.system);
expect(ThemePreference.parse('light'), ThemePreference.light);
expect(ThemePreference.parse('dark'), ThemePreference.dark);
});
test('resolve follows the platform only in system mode', () {
expect(ThemePreference.system.resolve(Brightness.dark), Brightness.dark);
expect(ThemePreference.light.resolve(Brightness.dark), Brightness.light);
expect(ThemePreference.dark.resolve(Brightness.light), Brightness.dark);
});
});
group('systemUiOverlayFor', () {
test('dark paints the Android nav bar transparent black without a scrim', () {
final overlay = systemUiOverlayFor(Brightness.dark);
expect(overlay.systemNavigationBarColor, const Color(0x00000000));
expect(overlay.systemNavigationBarContrastEnforced, isFalse);
expect(overlay.systemNavigationBarIconBrightness, Brightness.light);
});
});
group('ThemeController', () {
test('restore and setPreference persist independently of session', () async {
final store = MemoryThemeStore('dark');
final controller = ThemeController(store: store);
await controller.restore();
expect(controller.preference, ThemePreference.dark);
await controller.setPreference(ThemePreference.light);
expect(controller.preference, ThemePreference.light);
expect(store.value, 'light');
expect(controller.resolve(Brightness.dark), Brightness.light);
});
});
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:please_pay_me/app/app.dart';
import 'package:please_pay_me/core/config/app_config.dart';
import 'package:please_pay_me/core/storage/session_storage.dart';
import 'package:please_pay_me/data/demo/demo_backend.dart';
import 'package:please_pay_me/features/auth/session_controller.dart';
import 'package:please_pay_me/theme/theme.dart';
import 'support/test_setup.dart';
void main() {
setUpAll(setUpTestEnvironment);
SessionController demoSession({bool seeded = true}) {
return SessionController(
config: AppConfig.demo,
storage: InMemorySessionStorage(),
demoBackend: seeded ? DemoBackend() : DemoBackend.empty(),
);
}
/// Skeletons pulse forever, so `pumpAndSettle` would time out.
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
}
testWidgets('restoring session shows the branded splash', (tester) async {
final session = demoSession();
await tester.pumpWidget(PleasePayMeApp(session: session));
await tester.pump();
expect(find.text('Дожить до ЗП'), findsOneWidget);
expect(find.text('Бюджет от зарплаты до зарплаты'), findsOneWidget);
expect(find.byType(CupertinoActivityIndicator), findsOneWidget);
});
testWidgets('signed-out app shows the login screen', (tester) async {
final session = demoSession();
await session.restore();
await tester.pumpWidget(PleasePayMeApp(session: session));
await settle(tester);
expect(find.text('Дожить до ЗП'), findsOneWidget);
expect(find.text('Войти через Яндекс'), findsOneWidget);
});
testWidgets('demo session opens the overview with the current envelope',
(tester) async {
final session = demoSession()..startDemo();
await tester.pumpWidget(PleasePayMeApp(session: session));
await settle(tester);
expect(find.text('Обзор'), findsWidgets);
expect(find.text('До аванса'), findsOneWidget);
expect(find.text('Остаток бюджета'), findsOneWidget);
await tester.drag(find.byType(CustomScrollView).first, const Offset(0, -500));
await settle(tester);
// Grouped headers are uppercased by `AppSectionHeader`, as in iOS Settings.
expect(find.text('БЛИЖАЙШАЯ ВЫПЛАТА'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('tabs switch between the main sections', (tester) async {
final session = demoSession()..startDemo();
await tester.pumpWidget(PleasePayMeApp(session: session));
await settle(tester);
for (final tab in ['Журнал', 'Бюджеты', 'Работа', 'Профиль']) {
await tester.tap(find.text(tab).last);
await settle(tester);
expect(tester.takeException(), isNull, reason: 'tab $tab failed to render');
}
expect(find.text('Демо'), findsOneWidget);
});
testWidgets('profile theme switcher applies dark appearance', (tester) async {
final session = demoSession()..startDemo();
final theme = ThemeController(store: MemoryThemeStore());
await tester.pumpWidget(PleasePayMeApp(session: session, theme: theme));
await settle(tester);
await tester.tap(find.text('Профиль').last);
await settle(tester);
expect(find.text('Системная'), findsOneWidget);
expect(find.text('Светлая'), findsOneWidget);
await tester.tap(find.text('Тёмная'));
await settle(tester);
expect(theme.preference, ThemePreference.dark);
expect(theme.resolve(Brightness.light), Brightness.dark);
});
testWidgets('empty demo backend renders the empty states', (tester) async {
final session = demoSession(seeded: false)..startDemo();
await tester.pumpWidget(PleasePayMeApp(session: session));
await settle(tester);
expect(find.text('Бюджета пока нет'), findsOneWidget);
expect(find.text('Создать бюджет'), findsOneWidget);
});
testWidgets('recording an expense updates the remaining amount', (tester) async {
final session = demoSession()..startDemo();
await tester.pumpWidget(PleasePayMeApp(session: session));
await settle(tester);
await tester.tap(find.text('Добавить трату'));
await settle(tester);
expect(find.text('Новая трата'), findsOneWidget);
await tester.enterText(find.byType(CupertinoTextField).first, '1000');
await settle(tester);
await tester.tap(find.text('Записать трату'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Новая трата'), findsNothing);
expect(find.text('Трата записана'), findsOneWidget);
// Let the toast auto-dismiss so no timers outlive the test.
await tester.pump(const Duration(seconds: 3));
expect(tester.takeException(), isNull);
});
}