252 lines
9.5 KiB
Dart
252 lines
9.5 KiB
Dart
import 'dart:async';
|
||
import 'support/control_mode_fakes.dart';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:fpdart/fpdart.dart';
|
||
import 'package:maibu_satabot_v2/core/di/adapters/device_control_mode_realtime_adapter.dart';
|
||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
|
||
import 'package:maibu_satabot_v2/features/device_control_mode/data/datasources/control_mode_remote_datasource.dart';
|
||
import 'package:maibu_satabot_v2/features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart';
|
||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart';
|
||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart';
|
||
import 'package:maibu_satabot_v2/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/domain/entities/running_status_entity.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
|
||
|
||
class _Logger implements ILoggerService {
|
||
final lines = <String>[];
|
||
@override
|
||
void logWithLevel(
|
||
String message, {
|
||
String level = 'INFO',
|
||
Map<String, dynamic>? data,
|
||
bool shouldLog = false,
|
||
}) {
|
||
if (shouldLog) lines.add(message);
|
||
}
|
||
|
||
@override
|
||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||
}
|
||
|
||
class _StatusBloc extends Cubit<DeviceStatusState> implements DeviceStatusBloc {
|
||
_StatusBloc(String sn, String raw)
|
||
: listeningSn = sn,
|
||
super(
|
||
DeviceStatusUpdated(
|
||
RunningStatusEntity(controlMode: raw),
|
||
GPSEntity(0, 0),
|
||
),
|
||
);
|
||
String listeningSn;
|
||
final starts = <String>[];
|
||
@override
|
||
String? get listeningDeviceSn => listeningSn;
|
||
@override
|
||
void startMqttRealtimeListening(String sn) {
|
||
starts.add(sn);
|
||
if (listeningSn != sn) {
|
||
listeningSn = sn;
|
||
emit(DeviceStatusInitial());
|
||
}
|
||
}
|
||
|
||
void report(String raw) => emit(
|
||
DeviceStatusUpdated(RunningStatusEntity(controlMode: raw), GPSEntity(0, 0)),
|
||
);
|
||
@override
|
||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||
}
|
||
|
||
void main() {
|
||
test('接口按文档GET下发编码和整数模式,数字data=1不做bool强转', () async {
|
||
final dio = Dio();
|
||
final requests = <RequestOptions>[];
|
||
final logger = _Logger();
|
||
Map<String, dynamic> response = {'code': 200, 'msg': '操作成功', 'data': 1};
|
||
dio.interceptors.add(
|
||
InterceptorsWrapper(
|
||
onRequest: (options, handler) {
|
||
requests.add(options);
|
||
handler.resolve(
|
||
Response(requestOptions: options, statusCode: 200, data: response),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
final repo = DeviceControlModeRepositoryImpl(
|
||
realtime: (_) => const Stream.empty(),
|
||
remote: ControlModeRemoteDatasource(dio, logger),
|
||
);
|
||
final change = ChangeDeviceControlModeUseCase(repo);
|
||
for (final target in ControlModeTarget.values) {
|
||
expect((await change(' SN002 ', target)).isRight(), isTrue);
|
||
expect(requests.last.method, 'GET');
|
||
expect(requests.last.uri.path, '/iot/host/modeChage');
|
||
expect(requests.last.queryParameters, {
|
||
'deviceId': 'SN002',
|
||
'controlMode': target.value,
|
||
});
|
||
expect(requests.last.data, isNull);
|
||
}
|
||
response = {'code': 500, 'msg': '设备不在线'};
|
||
final failed = await change('SN002', ControlModeTarget.remote);
|
||
expect(
|
||
failed.fold((failure) => failure.message, (_) => ''),
|
||
contains('设备不在线'),
|
||
);
|
||
expect((await change(' ', ControlModeTarget.remote)).isLeft(), isTrue);
|
||
expect(requests, hasLength(3));
|
||
expect(logger.lines.any((s) => s.contains('"controlMode":3')), isTrue);
|
||
expect(logger.lines.any((s) => s.contains('HTTP 200')), isTrue);
|
||
dio.close();
|
||
});
|
||
|
||
test('实时适配器与设备详情共用Bloc,同设备复用快照,切换设备不串模式', () async {
|
||
final bloc = _StatusBloc('old', '3');
|
||
final readings = <DeviceControlModeReading>[];
|
||
final source = DeviceControlModeRealtimeAdapter(bloc);
|
||
var sub = source.watch('new').listen(readings.add);
|
||
await flushModes();
|
||
expect(bloc.starts, ['new']);
|
||
expect(readings.every((reading) => reading.rawMode == null), isTrue);
|
||
bloc.report('2');
|
||
await flushModes();
|
||
expect(readings.last.deviceId, 'new');
|
||
expect(readings.last.value, 2);
|
||
await sub.cancel();
|
||
readings.clear();
|
||
sub = source.watch('new').listen(readings.add);
|
||
await flushModes();
|
||
expect(readings.last.value, 2);
|
||
bloc.startMqttRealtimeListening('other');
|
||
bloc.report('3');
|
||
await flushModes();
|
||
expect(readings.last.rawMode, isNull);
|
||
await sub.cancel();
|
||
await bloc.close();
|
||
});
|
||
|
||
group('入口模式确认', () {
|
||
late FakeModes repo;
|
||
late ControlModeEntryCubit cubit;
|
||
setUp(() {
|
||
repo = FakeModes();
|
||
cubit = modeCubit(repo);
|
||
});
|
||
tearDown(() async {
|
||
if (!cubit.isClosed) await cubit.close();
|
||
await repo.reports.close();
|
||
});
|
||
Future<void> start(String? mode) async {
|
||
repo.initial = mode;
|
||
await cubit.start();
|
||
await flushModes();
|
||
}
|
||
|
||
test('无模式上报也允许用户确定下发,不等待topic', () async {
|
||
await start(null);
|
||
expect(cubit.state.canConfirm, isTrue);
|
||
expect(repo.requests, isEmpty);
|
||
await cubit.confirm();
|
||
expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous));
|
||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||
expect(cubit.state.reading?.rawMode, isNull);
|
||
});
|
||
test('已匹配时确定直接进入,不重复切换', () async {
|
||
await start('2');
|
||
await cubit.confirm();
|
||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||
expect(repo.requests, isEmpty);
|
||
});
|
||
test('文本模式可展示也可确定,不因非整数被禁用', () async {
|
||
await start('本地控制');
|
||
expect(cubit.state.reading!.hasReportedMode, isTrue);
|
||
expect(cubit.state.canConfirm, isTrue);
|
||
await cubit.confirm();
|
||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||
expect(cubit.state.reading!.rawMode, '本地控制');
|
||
});
|
||
test('接口成功立即进入,保持真实上报值而不伪造目标模式', () async {
|
||
await start('1');
|
||
await cubit.confirm();
|
||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||
expect(cubit.state.reading!.value, 1);
|
||
});
|
||
test('发送过程中重复点击只请求一次,成功后无需到达目标模式上报', () async {
|
||
await start('1');
|
||
repo.pending = Completer();
|
||
final first = cubit.confirm();
|
||
await cubit.confirm();
|
||
expect(cubit.state.busy, isTrue);
|
||
expect(repo.requests, hasLength(1));
|
||
repo.pending!.complete(const Right(unit));
|
||
await first;
|
||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||
});
|
||
test('失败后允许重试,同模式实时帧不抹掉失败信息', () async {
|
||
await start('1');
|
||
repo.failure = Failure('设备拒绝切换');
|
||
await cubit.confirm();
|
||
repo.report('1');
|
||
await flushModes();
|
||
expect(cubit.state.error, '设备拒绝切换');
|
||
expect(cubit.state.canConfirm, isTrue);
|
||
repo.failure = null;
|
||
await cubit.confirm();
|
||
expect(repo.requests, hasLength(2));
|
||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||
});
|
||
test('关闭后迟到HTTP响应不触发进入', () async {
|
||
await start('1');
|
||
repo.pending = Completer();
|
||
final request = cubit.confirm();
|
||
await cubit.close();
|
||
repo.pending!.complete(const Right(unit));
|
||
await request;
|
||
expect(cubit.state.phase, ControlModeEntryPhase.submitting);
|
||
});
|
||
test('远程入口下发3,接口成功后直接进入', () async {
|
||
await cubit.close();
|
||
cubit = modeCubit(repo, target: ControlModeTarget.remote);
|
||
await start('2');
|
||
await cubit.confirm();
|
||
expect(repo.requests.single, ('SN002', ControlModeTarget.remote));
|
||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||
expect(cubit.state.reading!.value, 2);
|
||
});
|
||
test('未知或协议外模式仍可手动确定下发', () async {
|
||
await start('0');
|
||
repo.report('6');
|
||
await flushModes();
|
||
expect(cubit.state.canConfirm, isTrue);
|
||
await cubit.confirm();
|
||
expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous));
|
||
});
|
||
test('其他设备上报不改变本设备模式', () async {
|
||
await start('1');
|
||
repo.report('2', deviceId: 'other');
|
||
await flushModes();
|
||
expect(cubit.state.reading!.value, 1);
|
||
await cubit.confirm();
|
||
expect(repo.requests, hasLength(1));
|
||
});
|
||
test('请求超时恢复确定按钮,不把迟到响应当成成功', () async {
|
||
await cubit.close();
|
||
cubit = modeCubit(repo, timeout: const Duration(milliseconds: 40));
|
||
await start('1');
|
||
repo.pending = Completer();
|
||
await cubit.confirm();
|
||
expect(cubit.state.errorKey, 'request_timeout');
|
||
expect(cubit.state.canConfirm, isTrue);
|
||
repo.pending!.complete(const Right(unit));
|
||
await flushModes();
|
||
expect(cubit.state.phase, ControlModeEntryPhase.failed);
|
||
});
|
||
});
|
||
}
|