601 lines
17 KiB
Dart
601 lines
17 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ignis_app/app/load_state.dart';
|
|
import 'package:ignis_app/models/ignis_group.dart';
|
|
import 'package:ignis_app/providers/providers.dart';
|
|
import 'package:ignis_app/services/api_client.dart';
|
|
|
|
class FakeIgnisApi extends IgnisApi {
|
|
Object? groupsData;
|
|
Object? groupStatusData;
|
|
Object? devicesData;
|
|
Object? scenesData;
|
|
Object? tasksData;
|
|
Object? statsData;
|
|
Object? eventLogData;
|
|
Object? apiKeysData;
|
|
Object? devicesError;
|
|
Object? scenesError;
|
|
Object? tasksError;
|
|
Object? statsError;
|
|
Object? eventLogError;
|
|
Object? apiKeysError;
|
|
Object? groupsError;
|
|
Object? groupStatusError;
|
|
Object? controlGroupError;
|
|
Object? cancelTaskError;
|
|
Object? revokeApiKeyError;
|
|
String? controlledGroupId;
|
|
Map<String, dynamic>? controlGroupParams;
|
|
int? requestedDays;
|
|
int? requestedLimit;
|
|
String? cancelledJobId;
|
|
String? revokedApiKey;
|
|
|
|
FakeIgnisApi({
|
|
this.groupsData,
|
|
this.groupStatusData,
|
|
this.devicesData,
|
|
this.scenesData,
|
|
this.tasksData,
|
|
this.statsData,
|
|
this.eventLogData,
|
|
this.apiKeysData,
|
|
});
|
|
|
|
@override
|
|
Future<Response> getDevices() async {
|
|
final error = devicesError;
|
|
if (error != null) throw error;
|
|
return Response(
|
|
requestOptions: RequestOptions(path: '/devices'),
|
|
data: devicesData,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Response> getScenes() async {
|
|
final error = scenesError;
|
|
if (error != null) throw error;
|
|
return Response(
|
|
requestOptions: RequestOptions(path: '/devices/scenes'),
|
|
data: scenesData,
|
|
);
|
|
}
|
|
|
|
@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,
|
|
);
|
|
}
|
|
|
|
@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> 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,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Response> revokeApiKey(String key) async {
|
|
revokedApiKey = key;
|
|
final error = revokeApiKeyError;
|
|
if (error != null) throw error;
|
|
return Response(
|
|
requestOptions: RequestOptions(path: '/api-keys/revoke'),
|
|
data: <String, dynamic>{'ok': true},
|
|
);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
ProviderContainer containerWith(FakeIgnisApi api) {
|
|
final container = ProviderContainer(
|
|
overrides: [apiProvider.overrideWithValue(api)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
return container;
|
|
}
|
|
|
|
test('stats load exposes data state', () async {
|
|
final api = FakeIgnisApi(
|
|
statsData: {
|
|
'groups': [
|
|
{'id': 'kitchen', 'total_commands': 3},
|
|
],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(statsProvider.notifier).load(days: 14);
|
|
|
|
final state = container.read(statsProvider);
|
|
expect(state.status, LoadStatus.data);
|
|
expect(state.data['groups'], hasLength(1));
|
|
expect(api.requestedDays, 14);
|
|
});
|
|
|
|
test('group status parser maps backend status shape', () {
|
|
final state = IgnisGroupState.firstFromStatusResponse({
|
|
'results': [
|
|
{
|
|
'status': {'state': true, 'dimming': 42, 'temp': 3000},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(state?.isOn, isTrue);
|
|
expect(state?.brightness, 42);
|
|
expect(state?.temp, 3000);
|
|
});
|
|
|
|
test('devices load exposes data state', () async {
|
|
final api = FakeIgnisApi(
|
|
devicesData: {
|
|
'devices': [
|
|
{'mac': 'AA:BB', 'name': 'Kitchen bulb'},
|
|
],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(devicesProvider.notifier).load();
|
|
|
|
final state = container.read(devicesProvider);
|
|
expect(state.status, LoadStatus.data);
|
|
expect(state.data, hasLength(1));
|
|
expect(state.data.single.groupMemberId, 'AA:BB');
|
|
expect(state.data.single.name, 'Kitchen bulb');
|
|
});
|
|
|
|
test('devices load exposes empty state', () async {
|
|
final api = FakeIgnisApi(devicesData: {'devices': <Object>[]});
|
|
final container = containerWith(api);
|
|
|
|
await container.read(devicesProvider.notifier).load();
|
|
|
|
final state = container.read(devicesProvider);
|
|
expect(state.status, LoadStatus.empty);
|
|
expect(state.data, isEmpty);
|
|
});
|
|
|
|
test('devices load error exposes message', () async {
|
|
final api = FakeIgnisApi(
|
|
devicesData: [
|
|
{'mac': 'AA:BB'},
|
|
],
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(devicesProvider.notifier).load();
|
|
api.devicesError = DioException(
|
|
requestOptions: RequestOptions(path: '/devices'),
|
|
type: DioExceptionType.connectionError,
|
|
message: 'No route to host',
|
|
);
|
|
|
|
await container.read(devicesProvider.notifier).load();
|
|
|
|
final state = container.read(devicesProvider);
|
|
expect(state.status, LoadStatus.error);
|
|
expect(state.data, hasLength(1));
|
|
expect(state.errorMessage, contains('Backend недоступен'));
|
|
});
|
|
|
|
test('tasks load exposes data state', () async {
|
|
final api = FakeIgnisApi(
|
|
tasksData: {
|
|
'tasks': [
|
|
{'id': 'job-1', 'target_id': 'kitchen'},
|
|
],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(tasksProvider.notifier).load();
|
|
|
|
final state = container.read(tasksProvider);
|
|
expect(state.status, LoadStatus.data);
|
|
expect(state.data, hasLength(1));
|
|
});
|
|
|
|
test('tasks load exposes empty state', () async {
|
|
final api = FakeIgnisApi(tasksData: {'tasks': <Object>[]});
|
|
final container = containerWith(api);
|
|
|
|
await container.read(tasksProvider.notifier).load();
|
|
|
|
final state = container.read(tasksProvider);
|
|
expect(state.status, LoadStatus.empty);
|
|
expect(state.data, isEmpty);
|
|
});
|
|
|
|
test('tasks load error exposes message', () async {
|
|
final api = FakeIgnisApi(
|
|
tasksData: [
|
|
{'id': 'job-1'},
|
|
],
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(tasksProvider.notifier).load();
|
|
api.tasksError = DioException(
|
|
requestOptions: RequestOptions(path: '/schedules/tasks'),
|
|
type: DioExceptionType.connectionError,
|
|
message: 'No route to host',
|
|
);
|
|
|
|
await container.read(tasksProvider.notifier).load();
|
|
|
|
final state = container.read(tasksProvider);
|
|
expect(state.status, LoadStatus.error);
|
|
expect(state.data, hasLength(1));
|
|
expect(state.errorMessage, contains('Backend недоступен'));
|
|
});
|
|
|
|
test('stats load exposes empty state for empty groups', () async {
|
|
final api = FakeIgnisApi(statsData: {'groups': <Object>[]});
|
|
final container = containerWith(api);
|
|
|
|
await container.read(statsProvider.notifier).load();
|
|
|
|
final state = container.read(statsProvider);
|
|
expect(state.status, LoadStatus.empty);
|
|
expect(state.data['groups'], isEmpty);
|
|
});
|
|
|
|
test('event log load accepts map response and exposes data state', () async {
|
|
final api = FakeIgnisApi(
|
|
eventLogData: {
|
|
'events': [
|
|
{'action': 'toggle', 'target_id': 'kitchen'},
|
|
],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(eventLogProvider.notifier).load(limit: 50);
|
|
|
|
final state = container.read(eventLogProvider);
|
|
expect(state.status, LoadStatus.data);
|
|
expect(state.data, hasLength(1));
|
|
expect(api.requestedLimit, 50);
|
|
});
|
|
|
|
test('scenes load accepts id-name map and exposes data state', () async {
|
|
final api = FakeIgnisApi(scenesData: {'party': 'Party', 'relax': 'Relax'});
|
|
final container = containerWith(api);
|
|
|
|
await container.read(scenesProvider.notifier).load();
|
|
|
|
final state = container.read(scenesProvider);
|
|
expect(state.status, LoadStatus.data);
|
|
expect(state.data, hasLength(2));
|
|
expect(state.data.first.id, 'party');
|
|
expect(state.data.first.displayName, 'Party');
|
|
});
|
|
|
|
test('scenes load maps numeric scene ids to display names', () async {
|
|
final api = FakeIgnisApi(
|
|
scenesData: {
|
|
'scenes': ['1', '4'],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(scenesProvider.notifier).load();
|
|
|
|
final state = container.read(scenesProvider);
|
|
expect(state.status, LoadStatus.data);
|
|
expect(state.data.first.id, '1');
|
|
expect(state.data.first.displayName, 'Океан');
|
|
expect(state.data.last.displayName, 'Вечеринка');
|
|
});
|
|
|
|
test('scenes load exposes empty state', () async {
|
|
final api = FakeIgnisApi(scenesData: {'scenes': <Object>[]});
|
|
final container = containerWith(api);
|
|
|
|
await container.read(scenesProvider.notifier).load();
|
|
|
|
final state = container.read(scenesProvider);
|
|
expect(state.status, LoadStatus.empty);
|
|
expect(state.data, isEmpty);
|
|
});
|
|
|
|
test('scenes load error exposes message', () async {
|
|
final api = FakeIgnisApi(scenesData: ['Party']);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(scenesProvider.notifier).load();
|
|
api.scenesError = DioException(
|
|
requestOptions: RequestOptions(path: '/devices/scenes'),
|
|
type: DioExceptionType.connectionError,
|
|
message: 'No route to host',
|
|
);
|
|
|
|
await container.read(scenesProvider.notifier).load();
|
|
|
|
final state = container.read(scenesProvider);
|
|
expect(state.status, LoadStatus.error);
|
|
expect(state.data, hasLength(1));
|
|
expect(state.errorMessage, contains('Backend недоступен'));
|
|
});
|
|
|
|
test('api keys load exposes data state', () async {
|
|
final api = FakeIgnisApi(
|
|
apiKeysData: {
|
|
'keys': [
|
|
{'name': 'guest', 'key': 'secret'},
|
|
],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(apiKeysProvider.notifier).load();
|
|
|
|
final state = container.read(apiKeysProvider);
|
|
expect(state.status, LoadStatus.data);
|
|
expect(state.data, hasLength(1));
|
|
});
|
|
|
|
test('api keys load exposes empty state', () async {
|
|
final api = FakeIgnisApi(apiKeysData: {'keys': <Object>[]});
|
|
final container = containerWith(api);
|
|
|
|
await container.read(apiKeysProvider.notifier).load();
|
|
|
|
final state = container.read(apiKeysProvider);
|
|
expect(state.status, LoadStatus.empty);
|
|
expect(state.data, isEmpty);
|
|
});
|
|
|
|
test('api keys load error exposes message', () async {
|
|
final api = FakeIgnisApi(
|
|
apiKeysData: [
|
|
{'name': 'guest'},
|
|
],
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(apiKeysProvider.notifier).load();
|
|
api.apiKeysError = DioException(
|
|
requestOptions: RequestOptions(path: '/api-keys'),
|
|
type: DioExceptionType.connectionError,
|
|
message: 'No route to host',
|
|
);
|
|
|
|
await container.read(apiKeysProvider.notifier).load();
|
|
|
|
final state = container.read(apiKeysProvider);
|
|
expect(state.status, LoadStatus.error);
|
|
expect(state.data, hasLength(1));
|
|
expect(state.errorMessage, contains('Backend недоступен'));
|
|
});
|
|
|
|
test('load error keeps previous stats data and exposes message', () async {
|
|
final api = FakeIgnisApi(
|
|
statsData: {
|
|
'groups': [
|
|
{'id': 'kitchen'},
|
|
],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(statsProvider.notifier).load();
|
|
api.statsError = DioException(
|
|
requestOptions: RequestOptions(path: '/stats/summary'),
|
|
type: DioExceptionType.connectionError,
|
|
message: 'No route to host',
|
|
);
|
|
|
|
await container.read(statsProvider.notifier).load();
|
|
|
|
final state = container.read(statsProvider);
|
|
expect(state.status, LoadStatus.error);
|
|
expect(state.data['groups'], hasLength(1));
|
|
expect(state.errorMessage, contains('Backend недоступен'));
|
|
});
|
|
|
|
test('groups refresh maps groups and status to typed state', () async {
|
|
final api = FakeIgnisApi(
|
|
groupsData: {
|
|
'kitchen': {
|
|
'name': 'Kitchen',
|
|
'macs': ['AA:BB'],
|
|
},
|
|
},
|
|
groupStatusData: {
|
|
'results': [
|
|
{
|
|
'status': {
|
|
'state': true,
|
|
'dimming': 42,
|
|
'temp': 3000,
|
|
'r': 1,
|
|
'g': 2,
|
|
'b': 3,
|
|
'scene': '4',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
);
|
|
final container = containerWith(api);
|
|
|
|
await container.read(groupsProvider.notifier).refresh();
|
|
|
|
final groups = container.read(groupsProvider);
|
|
expect(groups, hasLength(1));
|
|
expect(groups.single.id, 'kitchen');
|
|
expect(groups.single.name, 'Kitchen');
|
|
expect(groups.single.macs, ['AA:BB']);
|
|
expect(groups.single.state.isOn, isTrue);
|
|
expect(groups.single.state.brightness, 42);
|
|
expect(groups.single.state.sceneId, '4');
|
|
});
|
|
|
|
test('task cancel error is not swallowed', () async {
|
|
final api = FakeIgnisApi(tasksData: <Object>[]);
|
|
final container = containerWith(api);
|
|
final error = DioException(
|
|
requestOptions: RequestOptions(path: '/schedules/job-1'),
|
|
type: DioExceptionType.badResponse,
|
|
response: Response(
|
|
requestOptions: RequestOptions(path: '/schedules/job-1'),
|
|
statusCode: 500,
|
|
),
|
|
);
|
|
api.cancelTaskError = error;
|
|
|
|
await expectLater(
|
|
container.read(tasksProvider.notifier).cancel('job-1'),
|
|
throwsA(same(error)),
|
|
);
|
|
expect(api.cancelledJobId, 'job-1');
|
|
});
|
|
|
|
test('api key revoke error is not swallowed', () async {
|
|
final api = FakeIgnisApi(apiKeysData: <Object>[]);
|
|
final container = containerWith(api);
|
|
final error = DioException(
|
|
requestOptions: RequestOptions(path: '/api-keys/revoke'),
|
|
type: DioExceptionType.badResponse,
|
|
response: Response(
|
|
requestOptions: RequestOptions(path: '/api-keys/revoke'),
|
|
statusCode: 500,
|
|
),
|
|
);
|
|
api.revokeApiKeyError = error;
|
|
|
|
await expectLater(
|
|
container.read(apiKeysProvider.notifier).revoke('secret'),
|
|
throwsA(same(error)),
|
|
);
|
|
expect(api.revokedApiKey, 'secret');
|
|
});
|
|
|
|
test('set scene error is not swallowed', () async {
|
|
final api = FakeIgnisApi();
|
|
final container = containerWith(api);
|
|
final error = DioException(
|
|
requestOptions: RequestOptions(path: '/control/group/kitchen'),
|
|
type: DioExceptionType.badResponse,
|
|
response: Response(
|
|
requestOptions: RequestOptions(path: '/control/group/kitchen'),
|
|
statusCode: 500,
|
|
),
|
|
);
|
|
api.controlGroupError = error;
|
|
|
|
await expectLater(
|
|
container.read(groupsProvider.notifier).setScene('kitchen', 'party'),
|
|
throwsA(same(error)),
|
|
);
|
|
expect(api.controlledGroupId, 'kitchen');
|
|
expect(api.controlGroupParams, containsPair('scene', 'party'));
|
|
});
|
|
|
|
test('debounced group control error is reported', () async {
|
|
final api = FakeIgnisApi();
|
|
final container = containerWith(api);
|
|
api.controlGroupError = DioException(
|
|
requestOptions: RequestOptions(path: '/control/group/kitchen'),
|
|
type: DioExceptionType.connectionError,
|
|
message: 'No route to host',
|
|
);
|
|
|
|
container.read(groupsProvider.notifier).setBrightness('kitchen', 42);
|
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
|
|
|
final error = container.read(groupControlErrorProvider);
|
|
expect(error, isNotNull);
|
|
expect(error!.groupId, 'kitchen');
|
|
expect(error.action, 'яркость');
|
|
expect(error.message, contains('Backend недоступен'));
|
|
expect(api.controlGroupParams, containsPair('brightness', 42));
|
|
});
|
|
}
|