diff --git a/README.md b/README.md index 80695d4..3c74fd9 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,12 @@ flutter test Дополнительно тестами уже прикрыты: - typed parsing/load-state для основных backend-ответов; - geofence distance/runtime логика; -- чистая логика форм расписаний и групп. +- чистая логика форм расписаний и групп; +- provider-мутаторы для расписаний, таймера 4h и API-ключей; +- widget-сценарии форм домов, групп, расписаний и API-ключей; +- widget-сценарии `RemoteScreen`, `GroupCard` и error/retry-потоков. + +Сейчас baseline клиента закрывается примерно `60` тестами и уже ловит regressions не только в helper-логике, но и в основных пользовательских сценариях. ## Настройка @@ -134,6 +139,7 @@ API-ключи хранятся отдельно от конфигурации - Целевая платформа сейчас Android. - Release APK пока подписывается debug-ключом из Flutter-шаблона. - Build info в APK показывает дату сборки и короткий git hash текущего `HEAD`. Если сборка делается поверх незакоммиченного рабочего дерева, hash будет от последнего коммита, а не от локальных незакоммиченных изменений. +- Android-specific поведение реального background execution, уведомлений, runtime permissions и OEM battery restrictions пока подтверждается в основном ручными проверками на устройстве, а не automated integration-тестами. ## Лицензия diff --git a/lib/features/remote/providers/remote_providers.dart b/lib/features/remote/providers/remote_providers.dart index 181d1ed..712d002 100644 --- a/lib/features/remote/providers/remote_providers.dart +++ b/lib/features/remote/providers/remote_providers.dart @@ -112,7 +112,8 @@ class GroupsNotifier extends Notifier> { _timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); } - void stopPolling() => _stopPolling(); + void stopPolling({bool resetStatus = true}) => + _stopPolling(resetStatus: resetStatus); void _stopPolling({bool resetStatus = true}) { _timer?.cancel(); diff --git a/lib/screens/api_keys_screen.dart b/lib/screens/api_keys_screen.dart index f2d7843..33c0506 100644 --- a/lib/screens/api_keys_screen.dart +++ b/lib/screens/api_keys_screen.dart @@ -22,7 +22,7 @@ class _ApiKeysScreenState extends ConsumerState { @override void initState() { super.initState(); - _load(); + Future.microtask(_load); } Future _load() async { @@ -132,44 +132,46 @@ class _ApiKeysScreenState extends ConsumerState { builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( title: const Text('Новый API-ключ'), - content: Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: nameCtrl, - decoration: const InputDecoration( - labelText: 'Имя ключа', - hintText: 'Например: Гость', + content: SingleChildScrollView( + child: Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: 'Имя ключа', + hintText: 'Например: Гость', + ), + autofocus: true, + validator: (value) { + final normalized = value?.trim() ?? ''; + if (normalized.isEmpty) { + return 'Укажите имя ключа'; + } + if (normalized.length < 2) { + return 'Слишком короткое имя'; + } + return null; + }, ), - autofocus: true, - validator: (value) { - final normalized = value?.trim() ?? ''; - if (normalized.isEmpty) { - return 'Укажите имя ключа'; - } - if (normalized.length < 2) { - return 'Слишком короткое имя'; - } - return null; - }, - ), - const SizedBox(height: 12), - SwitchListTile( - title: const Text('Дать права администратора'), - subtitle: const Text( - 'Используйте только для доверенных людей', + const SizedBox(height: 12), + SwitchListTile( + title: const Text('Дать права администратора'), + subtitle: const Text( + 'Используйте только для доверенных людей', + ), + value: isAdmin, + activeThumbColor: Colors.deepOrange, + onChanged: isCreating + ? null + : (value) => setDialogState(() => isAdmin = value), + contentPadding: EdgeInsets.zero, ), - value: isAdmin, - activeThumbColor: Colors.deepOrange, - onChanged: isCreating - ? null - : (value) => setDialogState(() => isAdmin = value), - contentPadding: EdgeInsets.zero, - ), - ], + ], + ), ), ), actions: [ @@ -232,7 +234,9 @@ class _ApiKeysScreenState extends ConsumerState { ), ); - nameCtrl.dispose(); + WidgetsBinding.instance.addPostFrameCallback((_) { + nameCtrl.dispose(); + }); } Future _revokeKey(ApiKeyInfo data) async { diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index f4b5186..c141e79 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -22,15 +22,18 @@ class RemoteScreen extends ConsumerStatefulWidget { } class _RemoteScreenState extends ConsumerState { + late final GroupsNotifier _groupsNotifier; + @override void initState() { super.initState(); - Future.microtask(() => ref.read(groupsProvider.notifier).startPolling()); + _groupsNotifier = ref.read(groupsProvider.notifier); + Future.microtask(_groupsNotifier.startPolling); } @override void dispose() { - ref.read(groupsProvider.notifier).stopPolling(); + _groupsNotifier.stopPolling(resetStatus: false); super.dispose(); } diff --git a/lib/screens/schedules_screen.dart b/lib/screens/schedules_screen.dart index 13f3a02..66c827c 100644 --- a/lib/screens/schedules_screen.dart +++ b/lib/screens/schedules_screen.dart @@ -21,7 +21,7 @@ class _SchedulesScreenState extends ConsumerState { @override void initState() { super.initState(); - _load(); + Future.microtask(_load); } Future _load() async { diff --git a/test/error_retry_widget_test.dart b/test/error_retry_widget_test.dart new file mode 100644 index 0000000..404e6ff --- /dev/null +++ b/test/error_retry_widget_test.dart @@ -0,0 +1,154 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/screens/api_keys_screen.dart'; +import 'package:ignis_app/screens/group_edit_screen.dart'; +import 'package:ignis_app/screens/home_edit_screen.dart'; +import 'package:ignis_app/screens/schedules_screen.dart'; +import 'package:ignis_app/services/settings_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_support.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('schedules screen retries after initial load error', ( + tester, + ) async { + final api = FakeIgnisApi(); + api.tasksError = DioException( + requestOptions: RequestOptions(path: '/schedules/tasks'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await pumpTestApp(tester, child: const SchedulesScreen(), api: api); + await tester.pumpAndSettle(); + expect(find.text('Не удалось загрузить расписания'), findsOneWidget); + + api.tasksError = null; + api.tasksData = { + 'tasks': [ + { + 'id': 'job-1', + 'target_id': 'kitchen', + 'state': false, + 'hour': '7', + 'minute': '30', + 'day_of_week': '1,2,3', + 'type': 'cron', + }, + ], + }; + + await tester.tap(find.text('Повторить')); + await tester.pumpAndSettle(); + expect(find.text('Выключить группу kitchen'), findsOneWidget); + }); + + testWidgets('api keys screen retries after initial load error', ( + tester, + ) async { + final api = FakeIgnisApi(); + api.apiKeysError = DioException( + requestOptions: RequestOptions(path: '/api-keys'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await pumpTestApp(tester, child: const ApiKeysScreen(), api: api); + await tester.pumpAndSettle(); + expect(find.text('Не удалось загрузить API-ключи'), findsOneWidget); + + api.apiKeysError = null; + api.apiKeysData = { + 'keys': [ + {'name': 'Guest', 'key': 'secret', 'is_active': true}, + ], + }; + + await tester.tap(find.text('Повторить')); + await tester.pumpAndSettle(); + expect(find.text('Guest'), findsOneWidget); + }); + + testWidgets('group edit screen shows snackbar when rescan fails', ( + tester, + ) async { + final api = FakeIgnisApi(devicesData: {'devices': []}); + api.rescanNetworkError = DioException( + requestOptions: RequestOptions(path: '/devices/rescan'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await pumpTestApp(tester, child: const GroupEditScreen(), api: api); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Пересканировать сеть')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Ошибка сканирования'), findsOneWidget); + }); + + testWidgets('home edit screen shows save error when credentials fail', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final settingsService = SettingsService( + credentialsStorage: InMemoryCredentialsStorage(), + ); + final api = FakeIgnisApi(); + api.authError = DioException( + requestOptions: RequestOptions(path: '/auth/me'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/auth/me'), + statusCode: 403, + ), + ); + + await pumpTestApp( + tester, + api: api, + settingsService: settingsService, + child: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const HomeEditScreen())), + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Название'), + 'Дом', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Адрес сервера'), + 'ignis.akokos.ru', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'API Key'), + 'wrong-key', + ); + + final saveButton = find.widgetWithText(ElevatedButton, 'ДОБАВИТЬ'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + expect(find.textContaining('Не удалось сохранить дом'), findsOneWidget); + expect(await settingsService.getHomes(), isEmpty); + }); +} diff --git a/test/forms_widget_test.dart b/test/forms_widget_test.dart new file mode 100644 index 0000000..c5aa756 --- /dev/null +++ b/test/forms_widget_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/screens/api_keys_screen.dart'; +import 'package:ignis_app/screens/group_edit_screen.dart'; +import 'package:ignis_app/screens/home_edit_screen.dart'; +import 'package:ignis_app/screens/schedules_screen.dart'; +import 'package:ignis_app/services/settings_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_support.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('schedules screen creates one-shot schedule for selected group', ( + tester, + ) async { + final api = FakeIgnisApi( + tasksData: {'tasks': []}, + groupsData: { + 'bedroom': {'name': 'Спальня'}, + }, + ); + + await pumpTestApp(tester, child: const SchedulesScreen(), api: api); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Спальня').last); + await tester.pumpAndSettle(); + + final createScheduleButton = find.widgetWithText( + ElevatedButton, + 'СОЗДАТЬ РАСПИСАНИЕ', + ); + await tester.ensureVisible(createScheduleButton); + await tester.tap(createScheduleButton); + await tester.pumpAndSettle(); + + expect(api.scheduledOnceParams, isNotNull); + expect(api.scheduledOnceParams, containsPair('target_id', 'bedroom')); + expect(api.scheduledOnceParams, containsPair('is_group', true)); + expect(api.scheduledOnceParams?['run_at'], isA()); + }); + + testWidgets('group edit screen auto-generates id and creates group', ( + tester, + ) async { + final api = FakeIgnisApi( + devicesData: { + 'devices': [ + {'mac': 'AA:BB', 'name': 'Лампа 1'}, + ], + }, + groupsData: [], + ); + + await pumpTestApp(tester, child: const GroupEditScreen(), api: api); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Название группы'), + 'Спальня родителей', + ); + await tester.pump(); + + expect(find.text('spalnya-roditeley'), findsOneWidget); + + await tester.tap(find.text('Лампа 1')); + await tester.pump(); + + await tester.tap(find.text('СОЗДАТЬ ГРУППУ')); + await tester.pumpAndSettle(); + + expect(api.createdGroupId, 'spalnya-roditeley'); + expect(api.createdGroupName, 'Спальня родителей'); + expect(api.createdGroupMacs, ['AA:BB']); + }); + + testWidgets('api keys screen validates and shows created key banner', ( + tester, + ) async { + final api = FakeIgnisApi(apiKeysData: {'keys': []}); + + await pumpTestApp(tester, child: const ApiKeysScreen(), api: api); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Создать')); + await tester.pumpAndSettle(); + expect(find.text('Укажите имя ключа'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Имя ключа'), + 'Guest', + ); + await tester.tap(find.text('Дать права администратора')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Создать')); + await tester.pumpAndSettle(); + + expect(api.createdApiKeyName, 'Guest'); + expect(api.createdApiKeyIsAdmin, isTrue); + expect(find.textContaining('Новый ключ создан'), findsOneWidget); + expect(find.textContaining('Guest_token'), findsOneWidget); + }); + + testWidgets('home edit screen validates fields and saves normalized home', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final settingsService = SettingsService( + credentialsStorage: InMemoryCredentialsStorage(), + ); + final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'}); + + await pumpTestApp( + tester, + api: api, + settingsService: settingsService, + child: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const HomeEditScreen())), + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + final saveHomeButton = find.widgetWithText(ElevatedButton, 'ДОБАВИТЬ'); + await tester.tap(saveHomeButton); + await tester.pumpAndSettle(); + expect(find.text('Укажите название дома'), findsOneWidget); + expect(find.text('Укажите адрес сервера'), findsOneWidget); + expect(find.text('Укажите API key'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Название'), + 'Квартира', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Адрес сервера'), + 'ignis.akokos.ru/', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'API Key'), + 'secret-key', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Широта'), + '55.75', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Долгота'), + '37.61', + ); + await tester.pump(); + + await tester.tap(find.text('Выключать свет при уходе')); + await tester.pump(); + await tester.ensureVisible(saveHomeButton); + await tester.tap(saveHomeButton); + await tester.pumpAndSettle(); + + final homes = await settingsService.getHomes(); + final savedHome = homes.single; + final savedApiKey = await settingsService.getHomeApiKey(savedHome.id); + + expect(homes, hasLength(1)); + expect(savedHome.name, 'Квартира'); + expect(savedHome.url, 'https://ignis.akokos.ru'); + expect(savedHome.latitude, 55.75); + expect(savedHome.longitude, 37.61); + expect(savedHome.geofenceEnabled, isTrue); + expect(savedApiKey, 'secret-key'); + }); +} diff --git a/test/provider_mutation_test.dart b/test/provider_mutation_test.dart new file mode 100644 index 0000000..dfaae59 --- /dev/null +++ b/test/provider_mutation_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/app/load_state.dart'; +import 'package:ignis_app/providers/providers.dart'; + +import 'test_support.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('tasks addOnce sends runAt and reloads tasks', () async { + final api = FakeIgnisApi(tasksData: {'tasks': []}); + final container = createTestContainer(api); + + await container + .read(tasksProvider.notifier) + .addOnce( + targetId: 'bedroom', + targetState: false, + runAt: '2026-05-01T18:00:00Z', + ); + + expect(api.scheduledOnceParams, isNotNull); + expect(api.scheduledOnceParams, containsPair('target_id', 'bedroom')); + expect(api.scheduledOnceParams, containsPair('state', false)); + expect( + api.scheduledOnceParams, + containsPair('run_at', '2026-05-01T18:00:00Z'), + ); + expect(api.scheduledOnceParams, containsPair('is_group', true)); + expect(container.read(tasksProvider).status, LoadStatus.empty); + }); + + test( + 'tasks addCron sends normalized cron params and reloads tasks', + () async { + final api = FakeIgnisApi(tasksData: {'tasks': []}); + final container = createTestContainer(api); + + await container + .read(tasksProvider.notifier) + .addCron( + targetId: 'kitchen', + hour: '7', + minute: '30', + dayOfWeek: '1,2,3,4,5', + targetState: true, + ); + + expect(api.scheduledCronParams, isNotNull); + expect(api.scheduledCronParams, containsPair('target_id', 'kitchen')); + expect(api.scheduledCronParams, containsPair('hour', '7')); + expect(api.scheduledCronParams, containsPair('minute', '30')); + expect(api.scheduledCronParams, containsPair('day_of_week', '1,2,3,4,5')); + expect(api.scheduledCronParams, containsPair('state', true)); + expect(api.scheduledCronParams, containsPair('is_group', true)); + expect(container.read(tasksProvider).status, LoadStatus.empty); + }, + ); + + test('groups setTimer4h toggles on and schedules auto-off', () async { + final api = FakeIgnisApi(tasksData: {'tasks': []}); + final container = createTestContainer(api); + + await container.read(groupsProvider.notifier).setTimer4h('hall'); + + expect(api.controlledGroupId, 'hall'); + expect(api.controlGroupParams, containsPair('state', true)); + expect(api.scheduledOnceParams, containsPair('target_id', 'hall')); + expect(api.scheduledOnceParams, containsPair('state', false)); + expect(api.scheduledOnceParams, containsPair('hours_from_now', 4)); + expect(api.scheduledOnceParams, containsPair('is_group', true)); + }); + + test('api key create returns token and refreshes list', () async { + final api = FakeIgnisApi(apiKeysData: {'keys': []}); + final container = createTestContainer(api); + + final createdKey = await container + .read(apiKeysProvider.notifier) + .create('Guest', isAdmin: true); + + expect(createdKey, 'Guest_token'); + expect(api.createdApiKeyName, 'Guest'); + expect(api.createdApiKeyIsAdmin, isTrue); + final state = container.read(apiKeysProvider); + expect(state.status, LoadStatus.data); + expect(state.data.single.name, 'Guest'); + expect(state.data.single.isAdmin, isTrue); + }); +} diff --git a/test/remote_and_group_widget_test.dart b/test/remote_and_group_widget_test.dart new file mode 100644 index 0000000..7758a24 --- /dev/null +++ b/test/remote_and_group_widget_test.dart @@ -0,0 +1,215 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/models/ignis_group.dart'; +import 'package:ignis_app/providers/providers.dart'; +import 'package:ignis_app/screens/remote_screen.dart'; +import 'package:ignis_app/services/settings_service.dart'; +import 'package:ignis_app/widgets/group_card.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_support.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('remote screen shows api keys menu for admin only', ( + tester, + ) async { + final adminApi = FakeIgnisApi( + authData: {'is_admin': true, 'name': 'owner'}, + groupsData: [], + ); + final adminContainer = await _pumpRemoteScreen( + tester, + api: adminApi, + settingsService: await _seedSettingsService(), + ); + await adminContainer.read(authInfoProvider.notifier).load(); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + expect(find.text('API-ключи'), findsOneWidget); + + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + + final guestApi = FakeIgnisApi( + authData: {'is_admin': false, 'name': 'guest'}, + groupsData: [], + ); + final guestContainer = await _pumpRemoteScreen( + tester, + api: guestApi, + settingsService: await _seedSettingsService(), + ); + await guestContainer.read(authInfoProvider.notifier).load(); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + expect(find.text('API-ключи'), findsNothing); + }); + + testWidgets('remote screen deletes group after confirmation', (tester) async { + final api = FakeIgnisApi( + authData: {'is_admin': true, 'name': 'owner'}, + groupsData: { + 'kitchen': { + 'name': 'Kitchen', + 'macs': ['AA:BB'], + }, + }, + ); + final container = await _pumpRemoteScreen( + tester, + api: api, + settingsService: await _seedSettingsService(), + ); + + await container.read(groupsProvider.notifier).refresh(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Kitchen'), findsOneWidget); + + await tester.drag(find.text('Kitchen'), const Offset(-500, 0)); + await tester.pumpAndSettle(); + expect(find.text('Удалить группу?'), findsOneWidget); + + await tester.tap(find.text('Удалить')); + await tester.pumpAndSettle(); + + expect(api.deletedGroupId, 'kitchen'); + expect(find.text('Kitchen'), findsNothing); + }); + + testWidgets('group card toggles power and creates 4 hour timer', ( + tester, + ) async { + final api = FakeIgnisApi(tasksData: {'tasks': []}); + await pumpTestApp( + tester, + api: api, + child: Scaffold( + body: GroupCard( + group: const IgnisGroup( + id: 'hall', + name: 'Hall', + state: IgnisGroupState(isOn: true, brightness: 50), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.timer)); + await tester.pumpAndSettle(); + expect(api.controlledGroupId, 'hall'); + expect(api.controlGroupParams, containsPair('state', true)); + expect(api.scheduledOnceParams, containsPair('target_id', 'hall')); + expect(api.scheduledOnceParams, containsPair('hours_from_now', 4)); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(api.controlGroupParams, containsPair('state', false)); + }); + + testWidgets('group card loads scenes and applies selected scene', ( + tester, + ) async { + final api = FakeIgnisApi(scenesData: {'party': 'Party'}); + await pumpTestApp( + tester, + api: api, + child: Scaffold( + body: GroupCard( + group: const IgnisGroup( + id: 'hall', + name: 'Hall', + state: IgnisGroupState(isOn: true), + ), + ), + ), + ); + + await tester.tap(find.text('Сцена')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Party'), findsOneWidget); + await tester.tap(find.text('Party')); + await tester.pumpAndSettle(); + + expect(api.controlledGroupId, 'hall'); + expect(api.controlGroupParams, containsPair('scene', 'party')); + }); + + testWidgets('group card shows retry when scenes fail to load', ( + tester, + ) async { + final api = FakeIgnisApi(scenesData: {'party': 'Party'}); + api.scenesError = DioException( + requestOptions: RequestOptions(path: '/devices/scenes'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await pumpTestApp( + tester, + api: api, + child: Scaffold( + body: GroupCard( + group: const IgnisGroup( + id: 'hall', + name: 'Hall', + state: IgnisGroupState(isOn: true), + ), + ), + ), + ); + + await tester.tap(find.text('Сцена')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Не удалось загрузить сцены'), findsOneWidget); + + api.scenesError = null; + await tester.tap(find.text('Повторить')); + await tester.pumpAndSettle(); + expect(find.text('Party'), findsOneWidget); + }); +} + +Future _pumpRemoteScreen( + WidgetTester tester, { + required FakeIgnisApi api, + required SettingsService settingsService, +}) async { + final container = createTestContainer(api, settingsService: settingsService); + await container.read(currentHomeProvider.notifier).load(); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: RemoteScreen()), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + return container; +} + +Future _seedSettingsService() async { + SharedPreferences.setMockInitialValues({ + 'ignis_homes': + '[{"id":"home-1","name":"Home 1","url":"https://one.example","geofenceEnabled":false}]', + 'ignis_current_home_id': 'home-1', + }); + + final settingsService = SettingsService( + credentialsStorage: InMemoryCredentialsStorage(), + ); + await settingsService.setHomeApiKey('home-1', 'key-1'); + return settingsService; +} diff --git a/test/test_support.dart b/test/test_support.dart new file mode 100644 index 0000000..54167d2 --- /dev/null +++ b/test/test_support.dart @@ -0,0 +1,409 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/providers/providers.dart'; +import 'package:ignis_app/services/api_client.dart'; +import 'package:ignis_app/services/credentials_storage.dart'; +import 'package:ignis_app/services/settings_service.dart'; + +class InMemoryCredentialsStorage implements CredentialsStorage { + final Map _apiKeys = {}; + + @override + Future getApiKey(String homeId) async => _apiKeys[homeId]; + + @override + Future setApiKey(String homeId, String apiKey) async { + _apiKeys[homeId] = apiKey; + } + + @override + Future deleteApiKey(String homeId) async { + _apiKeys.remove(homeId); + } +} + +class FakeIgnisApi extends IgnisApi { + Object? groupsData; + Object? groupStatusData; + Object? devicesData; + Object? scenesData; + Object? tasksData; + Object? statsData; + Object? eventLogData; + Object? apiKeysData; + Object? authData; + + Object? devicesError; + Object? scenesError; + Object? tasksError; + Object? statsError; + Object? eventLogError; + Object? apiKeysError; + Object? authError; + Object? groupsError; + Object? groupStatusError; + Object? controlGroupError; + Object? cancelTaskError; + Object? revokeApiKeyError; + Object? activateApiKeyError; + Object? createApiKeyError; + Object? createGroupError; + Object? deleteGroupError; + Object? scheduleOnceError; + Object? scheduleCronError; + Object? rescanNetworkError; + + String? controlledGroupId; + Map? controlGroupParams; + int? requestedDays; + int? requestedLimit; + String? cancelledJobId; + String? revokedApiKey; + String? activatedApiKey; + String? createdApiKeyName; + bool? createdApiKeyIsAdmin; + String? createdGroupId; + String? createdGroupName; + List? createdGroupMacs; + String? deletedGroupId; + Map? scheduledOnceParams; + Map? scheduledCronParams; + int rescanCalls = 0; + + FakeIgnisApi({ + this.groupsData, + this.groupStatusData, + this.devicesData, + this.scenesData, + this.tasksData, + this.statsData, + this.eventLogData, + this.apiKeysData, + this.authData, + }); + + @override + Future getAuthMe() async { + final error = authError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/auth/me'), + data: authData ?? {'is_admin': false}, + ); + } + + @override + Future validateCredentials(String baseUrl, String apiKey) async { + final error = authError; + if (error != null) throw error; + } + + @override + Future getDevices() async { + final error = devicesError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/devices'), + data: devicesData ?? {'devices': []}, + ); + } + + @override + Future getScenes() async { + final error = scenesError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/devices/scenes'), + data: scenesData ?? [], + ); + } + + @override + Future getGroups() async { + final error = groupsError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/devices/groups'), + data: groupsData ?? [], + ); + } + + @override + Future getGroupStatus(String id) async { + final error = groupStatusError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/control/group/$id/status'), + data: + groupStatusData ?? + { + 'results': [ + { + 'status': {'state': false, 'dimming': 100, 'temp': 4000}, + }, + ], + }, + ); + } + + @override + Future controlGroup(String id, Map params) async { + controlledGroupId = id; + controlGroupParams = params; + final error = controlGroupError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/control/group/$id'), + data: {'ok': true}, + ); + } + + @override + Future getTasks() async { + final error = tasksError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/schedules/tasks'), + data: tasksData ?? {'tasks': []}, + ); + } + + @override + Future cancelTask(String jobId) async { + cancelledJobId = jobId; + final error = cancelTaskError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/schedules/$jobId'), + data: {'ok': true}, + ); + } + + @override + Future scheduleOnce(Map params) async { + scheduledOnceParams = Map.from(params); + final error = scheduleOnceError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/schedules/once'), + data: {'ok': true}, + ); + } + + @override + Future scheduleCron(Map params) async { + scheduledCronParams = Map.from(params); + final error = scheduleCronError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/schedules/cron'), + data: {'ok': true}, + ); + } + + @override + Future getStatsSummary({int days = 7}) async { + requestedDays = days; + final error = statsError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/stats/summary'), + data: statsData, + ); + } + + @override + Future getStatsLog({int limit = 100}) async { + requestedLimit = limit; + final error = eventLogError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/stats/log'), + data: eventLogData, + ); + } + + @override + Future getApiKeys() async { + final error = apiKeysError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/api-keys'), + data: apiKeysData ?? {'keys': []}, + ); + } + + @override + Future createApiKey(String name, {bool isAdmin = false}) async { + createdApiKeyName = name; + createdApiKeyIsAdmin = isAdmin; + final error = createApiKeyError; + if (error != null) throw error; + + final newKey = '${name}_token'; + apiKeysData = { + 'keys': [ + ...(apiKeysData is Map + ? List>.from( + (apiKeysData as Map)['keys'] as List? ?? const [], + ) + : const >[]), + {'name': name, 'key': newKey, 'is_admin': isAdmin, 'is_active': true}, + ], + }; + + return Response( + requestOptions: RequestOptions(path: '/api-keys'), + data: {'key': newKey}, + ); + } + + @override + Future revokeApiKey(String key) async { + revokedApiKey = key; + final error = revokeApiKeyError; + if (error != null) throw error; + apiKeysData = _mapApiKeys( + apiKeysData, + (item) => item['key'] == key ? {...item, 'is_active': false} : item, + ); + return Response( + requestOptions: RequestOptions(path: '/api-keys/revoke'), + data: {'ok': true}, + ); + } + + @override + Future activateApiKey(String key) async { + activatedApiKey = key; + final error = activateApiKeyError; + if (error != null) throw error; + apiKeysData = _mapApiKeys( + apiKeysData, + (item) => item['key'] == key ? {...item, 'is_active': true} : item, + ); + return Response( + requestOptions: RequestOptions(path: '/api-keys/activate'), + data: {'ok': true}, + ); + } + + @override + Future createGroup( + String id, + String name, + List macs, + ) async { + createdGroupId = id; + createdGroupName = name; + createdGroupMacs = List.from(macs); + final error = createGroupError; + if (error != null) throw error; + groupsData = _addGroup(groupsData, id, name, macs); + return Response( + requestOptions: RequestOptions(path: '/devices/groups'), + data: {'ok': true}, + ); + } + + @override + Future deleteGroup(String groupId) async { + deletedGroupId = groupId; + final error = deleteGroupError; + if (error != null) throw error; + groupsData = _removeGroup(groupsData, groupId); + return Response( + requestOptions: RequestOptions(path: '/devices/groups/$groupId'), + data: {'ok': true}, + ); + } + + @override + Future rescanNetwork() async { + rescanCalls += 1; + final error = rescanNetworkError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/devices/rescan'), + data: {'ok': true}, + ); + } +} + +ProviderContainer createTestContainer( + FakeIgnisApi api, { + SettingsService? settingsService, +}) { + final overrides = [apiProvider.overrideWithValue(api)]; + if (settingsService != null) { + overrides.add(settingsServiceProvider.overrideWithValue(settingsService)); + } + + final container = ProviderContainer(overrides: overrides); + addTearDown(container.dispose); + return container; +} + +Future pumpTestApp( + WidgetTester tester, { + required Widget child, + FakeIgnisApi? api, + SettingsService? settingsService, +}) async { + final container = createTestContainer( + api ?? FakeIgnisApi(), + settingsService: settingsService, + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp(home: child), + ), + ); + await tester.pump(); + return container; +} + +Object _mapApiKeys( + Object? source, + Map Function(Map) transform, +) { + final current = source is Map + ? List>.from(source['keys'] as List? ?? const []) + : >[]; + return { + 'keys': current + .map((item) => transform(Map.from(item))) + .toList(), + }; +} + +Object _addGroup(Object? source, String id, String name, List macs) { + if (source is Map) { + final next = Map.from(source); + next[id] = {'name': name, 'macs': macs}; + return next; + } + + final list = source is List ? List.from(source) : []; + list.add({'id': id, 'name': name, 'macs': macs}); + return list; +} + +Object _removeGroup(Object? source, String id) { + if (source is Map) { + final next = Map.from(source); + next.remove(id); + return next; + } + + if (source is List) { + return List.from(source) + ..removeWhere((item) => item is Map && item['id']?.toString() == id); + } + + return []; +}