test: expand client-side coverage and fix lifecycle issues
This commit is contained in:
@@ -111,7 +111,12 @@ flutter test
|
|||||||
Дополнительно тестами уже прикрыты:
|
Дополнительно тестами уже прикрыты:
|
||||||
- typed parsing/load-state для основных backend-ответов;
|
- typed parsing/load-state для основных backend-ответов;
|
||||||
- geofence distance/runtime логика;
|
- geofence distance/runtime логика;
|
||||||
- чистая логика форм расписаний и групп.
|
- чистая логика форм расписаний и групп;
|
||||||
|
- provider-мутаторы для расписаний, таймера 4h и API-ключей;
|
||||||
|
- widget-сценарии форм домов, групп, расписаний и API-ключей;
|
||||||
|
- widget-сценарии `RemoteScreen`, `GroupCard` и error/retry-потоков.
|
||||||
|
|
||||||
|
Сейчас baseline клиента закрывается примерно `60` тестами и уже ловит regressions не только в helper-логике, но и в основных пользовательских сценариях.
|
||||||
|
|
||||||
## Настройка
|
## Настройка
|
||||||
|
|
||||||
@@ -134,6 +139,7 @@ API-ключи хранятся отдельно от конфигурации
|
|||||||
- Целевая платформа сейчас Android.
|
- Целевая платформа сейчас Android.
|
||||||
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
|
- Release APK пока подписывается debug-ключом из Flutter-шаблона.
|
||||||
- Build info в APK показывает дату сборки и короткий git hash текущего `HEAD`. Если сборка делается поверх незакоммиченного рабочего дерева, hash будет от последнего коммита, а не от локальных незакоммиченных изменений.
|
- Build info в APK показывает дату сборки и короткий git hash текущего `HEAD`. Если сборка делается поверх незакоммиченного рабочего дерева, hash будет от последнего коммита, а не от локальных незакоммиченных изменений.
|
||||||
|
- Android-specific поведение реального background execution, уведомлений, runtime permissions и OEM battery restrictions пока подтверждается в основном ручными проверками на устройстве, а не automated integration-тестами.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ class GroupsNotifier extends Notifier<List<IgnisGroup>> {
|
|||||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopPolling() => _stopPolling();
|
void stopPolling({bool resetStatus = true}) =>
|
||||||
|
_stopPolling(resetStatus: resetStatus);
|
||||||
|
|
||||||
void _stopPolling({bool resetStatus = true}) {
|
void _stopPolling({bool resetStatus = true}) {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
Future<void>.microtask(_load);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
@@ -132,7 +132,8 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
builder: (ctx, setDialogState) => AlertDialog(
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: const Text('Новый API-ключ'),
|
title: const Text('Новый API-ключ'),
|
||||||
content: Form(
|
content: SingleChildScrollView(
|
||||||
|
child: Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -172,6 +173,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: isCreating ? null : () => Navigator.of(ctx).pop(),
|
onPressed: isCreating ? null : () => Navigator.of(ctx).pop(),
|
||||||
@@ -232,7 +234,9 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
nameCtrl.dispose();
|
nameCtrl.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _revokeKey(ApiKeyInfo data) async {
|
Future<void> _revokeKey(ApiKeyInfo data) async {
|
||||||
|
|||||||
@@ -22,15 +22,18 @@ class RemoteScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||||
|
late final GroupsNotifier _groupsNotifier;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Future.microtask(() => ref.read(groupsProvider.notifier).startPolling());
|
_groupsNotifier = ref.read(groupsProvider.notifier);
|
||||||
|
Future.microtask(_groupsNotifier.startPolling);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
ref.read(groupsProvider.notifier).stopPolling();
|
_groupsNotifier.stopPolling(resetStatus: false);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
Future<void>.microtask(_load);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
|
|||||||
154
test/error_retry_widget_test.dart
Normal file
154
test/error_retry_widget_test.dart
Normal file
@@ -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': <Object>[]});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
191
test/forms_widget_test.dart
Normal file
191
test/forms_widget_test.dart
Normal file
@@ -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': <Object>[]},
|
||||||
|
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<String>));
|
||||||
|
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<String>());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('group edit screen auto-generates id and creates group', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final api = FakeIgnisApi(
|
||||||
|
devicesData: {
|
||||||
|
'devices': [
|
||||||
|
{'mac': 'AA:BB', 'name': 'Лампа 1'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groupsData: <Object>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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': <Object>[]});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
90
test/provider_mutation_test.dart
Normal file
90
test/provider_mutation_test.dart
Normal file
@@ -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': <Object>[]});
|
||||||
|
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': <Object>[]});
|
||||||
|
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': <Object>[]});
|
||||||
|
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': <Object>[]});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
215
test/remote_and_group_widget_test.dart
Normal file
215
test/remote_and_group_widget_test.dart
Normal file
@@ -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: <Object>[],
|
||||||
|
);
|
||||||
|
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: <Object>[],
|
||||||
|
);
|
||||||
|
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': <Object>[]});
|
||||||
|
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<ProviderContainer> _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<SettingsService> _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;
|
||||||
|
}
|
||||||
409
test/test_support.dart
Normal file
409
test/test_support.dart
Normal file
@@ -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<String, String> _apiKeys = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getApiKey(String homeId) async => _apiKeys[homeId];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setApiKey(String homeId, String apiKey) async {
|
||||||
|
_apiKeys[homeId] = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<String, dynamic>? controlGroupParams;
|
||||||
|
int? requestedDays;
|
||||||
|
int? requestedLimit;
|
||||||
|
String? cancelledJobId;
|
||||||
|
String? revokedApiKey;
|
||||||
|
String? activatedApiKey;
|
||||||
|
String? createdApiKeyName;
|
||||||
|
bool? createdApiKeyIsAdmin;
|
||||||
|
String? createdGroupId;
|
||||||
|
String? createdGroupName;
|
||||||
|
List<String>? createdGroupMacs;
|
||||||
|
String? deletedGroupId;
|
||||||
|
Map<String, dynamic>? scheduledOnceParams;
|
||||||
|
Map<String, dynamic>? 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<Response> getAuthMe() async {
|
||||||
|
final error = authError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/auth/me'),
|
||||||
|
data: authData ?? <String, dynamic>{'is_admin': false},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> validateCredentials(String baseUrl, String apiKey) async {
|
||||||
|
final error = authError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> getDevices() async {
|
||||||
|
final error = devicesError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/devices'),
|
||||||
|
data: devicesData ?? <String, dynamic>{'devices': <Object>[]},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> getScenes() async {
|
||||||
|
final error = scenesError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/devices/scenes'),
|
||||||
|
data: scenesData ?? <Object>[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> getGroups() async {
|
||||||
|
final error = groupsError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/devices/groups'),
|
||||||
|
data: groupsData ?? <Object>[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> 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<Response> controlGroup(String id, Map<String, dynamic> params) async {
|
||||||
|
controlledGroupId = id;
|
||||||
|
controlGroupParams = params;
|
||||||
|
final error = controlGroupError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/control/group/$id'),
|
||||||
|
data: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> getTasks() async {
|
||||||
|
final error = tasksError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/schedules/tasks'),
|
||||||
|
data: tasksData ?? <String, dynamic>{'tasks': <Object>[]},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> cancelTask(String jobId) async {
|
||||||
|
cancelledJobId = jobId;
|
||||||
|
final error = cancelTaskError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/schedules/$jobId'),
|
||||||
|
data: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> scheduleOnce(Map<String, dynamic> params) async {
|
||||||
|
scheduledOnceParams = Map<String, dynamic>.from(params);
|
||||||
|
final error = scheduleOnceError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/schedules/once'),
|
||||||
|
data: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> scheduleCron(Map<String, dynamic> params) async {
|
||||||
|
scheduledCronParams = Map<String, dynamic>.from(params);
|
||||||
|
final error = scheduleCronError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/schedules/cron'),
|
||||||
|
data: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> 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<Response> 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<Response> getApiKeys() async {
|
||||||
|
final error = apiKeysError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/api-keys'),
|
||||||
|
data: apiKeysData ?? <String, dynamic>{'keys': <Object>[]},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> 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<Map<String, dynamic>>.from(
|
||||||
|
(apiKeysData as Map)['keys'] as List? ?? const [],
|
||||||
|
)
|
||||||
|
: const <Map<String, dynamic>>[]),
|
||||||
|
{'name': name, 'key': newKey, 'is_admin': isAdmin, 'is_active': true},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/api-keys'),
|
||||||
|
data: <String, dynamic>{'key': newKey},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> 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: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> 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: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> createGroup(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
List<String> macs,
|
||||||
|
) async {
|
||||||
|
createdGroupId = id;
|
||||||
|
createdGroupName = name;
|
||||||
|
createdGroupMacs = List<String>.from(macs);
|
||||||
|
final error = createGroupError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
groupsData = _addGroup(groupsData, id, name, macs);
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/devices/groups'),
|
||||||
|
data: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> 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: <String, dynamic>{'ok': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response> rescanNetwork() async {
|
||||||
|
rescanCalls += 1;
|
||||||
|
final error = rescanNetworkError;
|
||||||
|
if (error != null) throw error;
|
||||||
|
return Response(
|
||||||
|
requestOptions: RequestOptions(path: '/devices/rescan'),
|
||||||
|
data: <String, dynamic>{'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<ProviderContainer> 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<String, dynamic> Function(Map<String, dynamic>) transform,
|
||||||
|
) {
|
||||||
|
final current = source is Map
|
||||||
|
? List<Map<String, dynamic>>.from(source['keys'] as List? ?? const [])
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
return {
|
||||||
|
'keys': current
|
||||||
|
.map((item) => transform(Map<String, dynamic>.from(item)))
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Object _addGroup(Object? source, String id, String name, List<String> macs) {
|
||||||
|
if (source is Map) {
|
||||||
|
final next = Map<String, dynamic>.from(source);
|
||||||
|
next[id] = {'name': name, 'macs': macs};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = source is List ? List<Object>.from(source) : <Object>[];
|
||||||
|
list.add({'id': id, 'name': name, 'macs': macs});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object _removeGroup(Object? source, String id) {
|
||||||
|
if (source is Map) {
|
||||||
|
final next = Map<String, dynamic>.from(source);
|
||||||
|
next.remove(id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source is List) {
|
||||||
|
return List<Object>.from(source)
|
||||||
|
..removeWhere((item) => item is Map && item['id']?.toString() == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Object>[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user