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? 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 getDevices() async { final error = devicesError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/devices'), data: devicesData, ); } @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, ); } @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 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, ); } @override Future revokeApiKey(String key) async { revokedApiKey = key; final error = revokeApiKeyError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/api-keys/revoke'), data: {'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': []}); 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': []}); 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': []}); 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': []}); 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': []}); 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: []); 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: []); 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.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)); }); }