diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 470f160..e8956f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,6 @@ jobs: - name: Analyze project source run: flutter analyze lib --fatal-infos + + - name: Run tests + run: flutter test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 301183f..ee0aebd 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ android/app/google-services.json ios/firebase_app_id_file.json ios/Runner/GoogleService-Info.plist lib/firebase_options.dart + +coverage/ \ No newline at end of file diff --git a/lib/interactors/metering_interactor.dart b/lib/interactors/metering_interactor.dart index 2cf1fc9..ca94f8e 100644 --- a/lib/interactors/metering_interactor.dart +++ b/lib/interactors/metering_interactor.dart @@ -31,6 +31,7 @@ class MeteringInteractor { double get cameraEvCalibration => _userPreferencesService.cameraEvCalibration; double get lightSensorEvCalibration => _userPreferencesService.lightSensorEvCalibration; + bool get isHapticsEnabled => _userPreferencesService.haptics; IsoValue get iso => _userPreferencesService.iso; set iso(IsoValue value) => _userPreferencesService.iso = value; diff --git a/lib/screens/metering/bloc_metering.dart b/lib/screens/metering/bloc_metering.dart index 4ebe422..042991a 100644 --- a/lib/screens/metering/bloc_metering.dart +++ b/lib/screens/metering/bloc_metering.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'dart:math'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/data/models/film.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; @@ -15,115 +15,130 @@ import 'package:lightmeter/screens/metering/state_metering.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringBloc extends Bloc { - final MeteringCommunicationBloc _communicationBloc; final MeteringInteractor _meteringInteractor; + final MeteringCommunicationBloc _communicationBloc; late final StreamSubscription _communicationSubscription; - List get _apertureValues => - _equipmentProfileData.apertureValues.whereStopType(stopType); - List get _shutterSpeedValues => - _equipmentProfileData.shutterSpeedValues.whereStopType(stopType); - - EquipmentProfileData _equipmentProfileData; - StopType stopType; - - late IsoValue _iso = _meteringInteractor.iso; - late NdValue _nd = _meteringInteractor.ndFilter; - late Film _film = _meteringInteractor.film; - double? _ev100 = 0.0; - bool _isMeteringInProgress = false; - MeteringBloc( - this._communicationBloc, this._meteringInteractor, - this._equipmentProfileData, - this.stopType, + this._communicationBloc, ) : super( MeteringDataState( - ev: null, + ev100: null, film: _meteringInteractor.film, iso: _meteringInteractor.iso, nd: _meteringInteractor.ndFilter, - exposurePairs: const [], - continuousMetering: false, + isMetering: false, ), ) { _communicationSubscription = _communicationBloc.stream .where((state) => state is communication_states.ScreenState) .map((state) => state as communication_states.ScreenState) - .listen(_onCommunicationState); + .listen(onCommunicationState); on(_onEquipmentProfileChanged); - on(_onStopTypeChanged); on(_onFilmChanged); on(_onIsoChanged); on(_onNdChanged); - on(_onMeasure); + on(_onMeasure, transformer: droppable()); on(_onMeasured); on(_onMeasureError); } + @override + void onTransition(Transition transition) { + super.onTransition(transition); + if (transition.nextState is MeteringDataState) { + final nextState = transition.nextState as MeteringDataState; + if (transition.currentState is LoadingState || + transition.currentState is MeteringDataState && + (transition.currentState as MeteringDataState).ev != nextState.ev) { + if (nextState.hasError) { + _meteringInteractor.errorVibration(); + } else { + _meteringInteractor.responseVibration(); + } + } + } + } + @override Future close() async { await _communicationSubscription.cancel(); return super.close(); } - void _onCommunicationState(communication_states.ScreenState communicationState) { + @visibleForTesting + void onCommunicationState(communication_states.ScreenState communicationState) { if (communicationState is communication_states.MeasuredState) { - _isMeteringInProgress = communicationState is communication_states.MeteringInProgressState; - _handleEv100(communicationState.ev100); - } - } - - void _onStopTypeChanged(StopTypeChangedEvent event, Emitter emit) { - if (stopType != event.stopType) { - stopType = event.stopType; - _updateMeasurements(); + _handleEv100( + communicationState.ev100, + isMetering: communicationState is communication_states.MeteringInProgressState, + ); } } void _onEquipmentProfileChanged(EquipmentProfileChangedEvent event, Emitter emit) { - _equipmentProfileData = event.equipmentProfileData; bool willUpdateMeasurements = false; - /// Update selected ISO value, if selected equipment profile + /// Update selected ISO value and discard selected film, if selected equipment profile /// doesn't contain currently selected value - if (!event.equipmentProfileData.isoValues.any((v) => _iso.value == v.value)) { + IsoValue iso = state.iso; + Film film = state.film; + if (!event.equipmentProfileData.isoValues.any((v) => state.iso.value == v.value)) { _meteringInteractor.iso = event.equipmentProfileData.isoValues.first; - _iso = event.equipmentProfileData.isoValues.first; - willUpdateMeasurements &= true; + iso = event.equipmentProfileData.isoValues.first; + _meteringInteractor.film = Film.values.first; + film = Film.values.first; + willUpdateMeasurements = true; } /// The same for ND filter - if (!event.equipmentProfileData.ndValues.any((v) => _nd.value == v.value)) { + NdValue nd = state.nd; + if (!event.equipmentProfileData.ndValues.any((v) => state.nd.value == v.value)) { _meteringInteractor.ndFilter = event.equipmentProfileData.ndValues.first; - _nd = event.equipmentProfileData.ndValues.first; - willUpdateMeasurements &= true; + nd = event.equipmentProfileData.ndValues.first; + willUpdateMeasurements = true; } if (willUpdateMeasurements) { - _updateMeasurements(); + emit( + MeteringDataState( + ev100: state.ev100, + film: film, + iso: iso, + nd: nd, + isMetering: state.isMetering, + ), + ); } } void _onFilmChanged(FilmChangedEvent event, Emitter emit) { - if (_film.name != event.data.name) { - _meteringInteractor.film = event.data; - _film = event.data; + if (state.film.name != event.film.name) { + _meteringInteractor.film = event.film; + + /// Find `IsoValue` with matching value + IsoValue iso = state.iso; + if (state.iso.value != event.film.iso && event.film != const Film.other()) { + iso = IsoValue.values.firstWhere( + (e) => e.value == event.film.iso, + orElse: () => state.iso, + ); + _meteringInteractor.iso = iso; + } /// If user selects 'Other' film we preserve currently selected ISO /// and therefore only discard reciprocity formula - if (_iso.value != event.data.iso && event.data != const Film.other()) { - final newIso = IsoValue.values.firstWhere( - (e) => e.value == event.data.iso, - orElse: () => _iso, - ); - _meteringInteractor.iso = newIso; - _iso = newIso; - } - - _updateMeasurements(); + emit( + MeteringDataState( + ev100: state.ev100, + film: event.film, + iso: iso, + nd: state.nd, + isMetering: state.isMetering, + ), + ); } } @@ -132,131 +147,77 @@ class MeteringBloc extends Bloc { /// because, for example, Fomapan 400 and any Ilford 400 /// have different reciprocity formulas _meteringInteractor.film = Film.values.first; - _film = Film.values.first; - if (_iso != event.isoValue) { + if (state.iso != event.isoValue) { _meteringInteractor.iso = event.isoValue; - _iso = event.isoValue; - _updateMeasurements(); + emit( + MeteringDataState( + ev100: state.ev100, + film: Film.values.first, + iso: event.isoValue, + nd: state.nd, + isMetering: state.isMetering, + ), + ); } } void _onNdChanged(NdChangedEvent event, Emitter emit) { - if (_nd != event.ndValue) { + if (state.nd != event.ndValue) { _meteringInteractor.ndFilter = event.ndValue; - _nd = event.ndValue; - _updateMeasurements(); + emit( + MeteringDataState( + ev100: state.ev100, + film: state.film, + iso: state.iso, + nd: event.ndValue, + isMetering: state.isMetering, + ), + ); } } void _onMeasure(MeasureEvent _, Emitter emit) { _meteringInteractor.quickVibration(); _communicationBloc.add(const communication_events.MeasureEvent()); - _isMeteringInProgress = true; emit( LoadingState( - film: _film, - iso: _iso, - nd: _nd, + film: state.film, + iso: state.iso, + nd: state.nd, ), ); } - void _updateMeasurements() => _handleEv100(_ev100); - - void _handleEv100(double? ev100) { + void _handleEv100(double? ev100, {required bool isMetering}) { if (ev100 == null || ev100.isNaN || ev100.isInfinite) { - add(const MeasureErrorEvent()); + add(MeasureErrorEvent(isMetering: isMetering)); } else { - add(MeasuredEvent(ev100)); + add(MeasuredEvent(ev100, isMetering: isMetering)); } } void _onMeasured(MeasuredEvent event, Emitter emit) { - _meteringInteractor.responseVibration(); - _ev100 = event.ev100; - final ev = event.ev100 + log2(_iso.value / 100) - _nd.stopReduction; emit( MeteringDataState( - ev: ev, - film: _film, - iso: _iso, - nd: _nd, - exposurePairs: _buildExposureValues(ev), - continuousMetering: _isMeteringInProgress, + ev100: event.ev100, + film: state.film, + iso: state.iso, + nd: state.nd, + isMetering: event.isMetering, ), ); } - void _onMeasureError(MeasureErrorEvent _, Emitter emit) { - _meteringInteractor.errorVibration(); - _ev100 = null; + void _onMeasureError(MeasureErrorEvent event, Emitter emit) { emit( MeteringDataState( - ev: null, - film: _film, - iso: _iso, - nd: _nd, - exposurePairs: const [], - continuousMetering: _isMeteringInProgress, + ev100: null, + film: state.film, + iso: state.iso, + nd: state.nd, + isMetering: event.isMetering, ), ); } - - List _buildExposureValues(double ev) { - if (ev.isNaN || ev.isInfinite) { - return List.empty(); - } - - /// Depending on the `stopType` the exposure pairs list length is multiplied by 1,2 or 3 - final int evSteps = (ev * (stopType.index + 1)).round(); - - /// Basically we use 1" shutter speed as an anchor point for building the exposure pairs list. - /// But user can exclude this value from the list using custom equipment profile. - /// So we have to restore the index of the anchor value. - const ShutterSpeedValue anchorShutterSpeed = ShutterSpeedValue(1, false, StopType.full); - int anchorIndex = _shutterSpeedValues.indexOf(anchorShutterSpeed); - if (anchorIndex < 0) { - final filteredFullList = ShutterSpeedValue.values.whereStopType(stopType); - final customListStartIndex = filteredFullList.indexOf(_shutterSpeedValues.first); - final fullListAnchor = filteredFullList.indexOf(anchorShutterSpeed); - if (customListStartIndex < fullListAnchor) { - /// This means, that user excluded anchor value at the end, - /// i.e. all shutter speed values are shorter than 1". - anchorIndex = fullListAnchor - customListStartIndex; - } else { - /// In case user excludes anchor value at the start, - /// we can do no adjustment. - } - } - final int evOffset = anchorIndex - evSteps; - - late final int apertureOffset; - late final int shutterSpeedOffset; - if (evOffset >= 0) { - apertureOffset = 0; - shutterSpeedOffset = evOffset; - } else { - apertureOffset = -evOffset; - shutterSpeedOffset = 0; - } - - final int itemsCount = min( - _apertureValues.length + shutterSpeedOffset, - _shutterSpeedValues.length + apertureOffset, - ) - - max(apertureOffset, shutterSpeedOffset); - - if (itemsCount < 0) { - return List.empty(); - } - return List.generate( - itemsCount, - (index) => ExposurePair( - _apertureValues[index + apertureOffset], - _film.reciprocityFailure(_shutterSpeedValues[index + shutterSpeedOffset]), - ), - growable: false, - ); - } } diff --git a/lib/screens/metering/communication/event_communication_metering.dart b/lib/screens/metering/communication/event_communication_metering.dart index 7c00173..ac63b57 100644 --- a/lib/screens/metering/communication/event_communication_metering.dart +++ b/lib/screens/metering/communication/event_communication_metering.dart @@ -22,8 +22,28 @@ abstract class MeasuredEvent extends SourceEvent { class MeteringInProgressEvent extends MeasuredEvent { const MeteringInProgressEvent(super.ev100); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is MeteringInProgressEvent && other.ev100 == ev100; + } + + @override + int get hashCode => Object.hash(ev100, runtimeType); } class MeteringEndedEvent extends MeasuredEvent { const MeteringEndedEvent(super.ev100); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is MeteringEndedEvent && other.ev100 == ev100; + } + + @override + int get hashCode => Object.hash(ev100, runtimeType); } diff --git a/lib/screens/metering/communication/state_communication_metering.dart b/lib/screens/metering/communication/state_communication_metering.dart index 06bedaf..2923cf1 100644 --- a/lib/screens/metering/communication/state_communication_metering.dart +++ b/lib/screens/metering/communication/state_communication_metering.dart @@ -1,4 +1,4 @@ -abstract class MeteringCommunicationState { +sealed class MeteringCommunicationState { const MeteringCommunicationState(); } @@ -6,11 +6,11 @@ class InitState extends MeteringCommunicationState { const InitState(); } -abstract class SourceState extends MeteringCommunicationState { +sealed class SourceState extends MeteringCommunicationState { const SourceState(); } -abstract class ScreenState extends MeteringCommunicationState { +sealed class ScreenState extends MeteringCommunicationState { const ScreenState(); } @@ -18,7 +18,7 @@ class MeasureState extends SourceState { const MeasureState(); } -abstract class MeasuredState extends ScreenState { +sealed class MeasuredState extends ScreenState { final double? ev100; const MeasuredState(this.ev100); @@ -26,8 +26,28 @@ abstract class MeasuredState extends ScreenState { class MeteringInProgressState extends MeasuredState { const MeteringInProgressState(super.ev100); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is MeteringInProgressState && other.ev100 == ev100; + } + + @override + int get hashCode => Object.hash(ev100, runtimeType); } class MeteringEndedState extends MeasuredState { const MeteringEndedState(super.ev100); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is MeteringEndedState && other.ev100 == ev100; + } + + @override + int get hashCode => Object.hash(ev100, runtimeType); } diff --git a/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart b/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart index 80819ca..99bd2ae 100644 --- a/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart +++ b/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart @@ -6,13 +6,11 @@ import 'package:lightmeter/screens/shared/filled_circle/widget_circle_filled.dar class MeteringMeasureButton extends StatefulWidget { final double? ev; final bool isMetering; - final bool hasError; final VoidCallback onTap; const MeteringMeasureButton({ required this.ev, required this.isMetering, - required this.hasError, required this.onTap, super.key, }); @@ -34,58 +32,49 @@ class _MeteringMeasureButtonState extends State { @override Widget build(BuildContext context) { - return IgnorePointer( - ignoring: widget.isMetering && widget.ev == null && !widget.hasError, - child: GestureDetector( - onTap: widget.onTap, - onTapDown: (_) { - setState(() { - _isPressed = true; - }); - }, - onTapUp: (_) { - setState(() { - _isPressed = false; - }); - }, - onTapCancel: () { - setState(() { - _isPressed = false; - }); - }, - child: SizedBox.fromSize( - size: const Size.square(Dimens.grid72), - child: Stack( - children: [ - Center( - child: AnimatedScale( - duration: Dimens.durationS, - scale: _isPressed ? 0.9 : 1.0, - child: FilledCircle( - color: Theme.of(context).colorScheme.onSurface, - size: Dimens.grid72 - Dimens.grid8, - child: Center( - child: widget.hasError - ? Icon( - Icons.error_outline, - color: Theme.of(context).colorScheme.surface, - size: Dimens.grid24, - ) - : (widget.ev != null ? _EvValueText(ev: widget.ev!) : null), - ), + return GestureDetector( + onTap: widget.onTap, + onTapDown: (_) { + setState(() { + _isPressed = true; + }); + }, + onTapUp: (_) { + setState(() { + _isPressed = false; + }); + }, + onTapCancel: () { + setState(() { + _isPressed = false; + }); + }, + child: SizedBox.fromSize( + size: const Size.square(Dimens.grid72), + child: Stack( + children: [ + Center( + child: AnimatedScale( + duration: Dimens.durationS, + scale: _isPressed ? 0.9 : 1.0, + child: FilledCircle( + color: Theme.of(context).colorScheme.onSurface, + size: Dimens.grid72 - Dimens.grid8, + child: Center( + child: widget.ev != null ? _EvValueText(ev: widget.ev!) : null, ), ), ), - Positioned.fill( - child: CircularProgressIndicator( - /// This key is needed to make indicator start from the same point every time - key: ValueKey(widget.isMetering), - color: Theme.of(context).colorScheme.onSurface, - value: widget.isMetering ? null : 1, - ), + ), + Positioned.fill( + child: CircularProgressIndicator( + /// This key is needed to make indicator start from the same point every time + key: ValueKey(widget.isMetering), + color: Theme.of(context).colorScheme.onSurface, + value: widget.isMetering ? null : 1, ), - ], - ), + ), + ], ), ), ); diff --git a/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart b/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart index 4d07889..dd4a9be 100644 --- a/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart +++ b/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart @@ -6,7 +6,6 @@ import 'package:lightmeter/screens/metering/components/bottom_controls/widget_bo class MeteringBottomControlsProvider extends StatelessWidget { final double? ev; final bool isMetering; - final bool hasError; final VoidCallback? onSwitchEvSourceType; final VoidCallback onMeasure; final VoidCallback onSettings; @@ -14,7 +13,6 @@ class MeteringBottomControlsProvider extends StatelessWidget { const MeteringBottomControlsProvider({ required this.ev, required this.isMetering, - required this.hasError, required this.onSwitchEvSourceType, required this.onMeasure, required this.onSettings, @@ -38,7 +36,6 @@ class MeteringBottomControlsProvider extends StatelessWidget { child: MeteringBottomControls( ev: ev, isMetering: isMetering, - hasError: hasError, onSwitchEvSourceType: onSwitchEvSourceType, onMeasure: onMeasure, onSettings: onSettings, diff --git a/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart b/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart index 96cd7b6..9989494 100644 --- a/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart +++ b/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart @@ -7,7 +7,6 @@ import 'package:lightmeter/utils/inherited_generics.dart'; class MeteringBottomControls extends StatelessWidget { final double? ev; final bool isMetering; - final bool hasError; final VoidCallback? onSwitchEvSourceType; final VoidCallback onMeasure; final VoidCallback onSettings; @@ -15,7 +14,6 @@ class MeteringBottomControls extends StatelessWidget { const MeteringBottomControls({ required this.ev, required this.isMetering, - required this.hasError, required this.onSwitchEvSourceType, required this.onMeasure, required this.onSettings, @@ -56,7 +54,6 @@ class MeteringBottomControls extends StatelessWidget { MeteringMeasureButton( ev: ev, isMetering: isMetering, - hasError: hasError, onTap: onMeasure, ), Expanded( diff --git a/lib/screens/metering/components/camera_container/bloc_container_camera.dart b/lib/screens/metering/components/camera_container/bloc_container_camera.dart index 877c0ef..2bb8ed1 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -53,8 +53,6 @@ class CameraContainerBloc extends EvSourceBlocBase(_onZoomChanged); on(_onExposureOffsetChanged); on(_onExposureOffsetResetEvent); - - add(const RequestPermissionEvent()); } @override @@ -124,7 +122,7 @@ class CameraContainerBloc extends EvSourceBlocBase([ _cameraController!.getMinZoomLevel(), _cameraController!.getMaxZoomLevel(), - ]).then((levels) => RangeValues(levels[0], math.min(_maxZoom, levels[1]))); + ]).then((levels) => RangeValues(math.max(1.0, levels[0]), math.min(_maxZoom, levels[1]))); _currentZoom = _zoomRange!.start; _exposureOffsetRange = await Future.wait([ @@ -210,8 +208,8 @@ class CameraContainerBloc extends EvSourceBlocBase Object.hash(value, runtimeType); } class ExposureOffsetChangedEvent extends CameraContainerEvent { final double value; const ExposureOffsetChangedEvent(this.value); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is ExposureOffsetChangedEvent && other.value == value; + } + + @override + int get hashCode => Object.hash(value, runtimeType); } class ExposureOffsetResetEvent extends CameraContainerEvent { diff --git a/lib/screens/metering/components/camera_container/provider_container_camera.dart b/lib/screens/metering/components/camera_container/provider_container_camera.dart index 81357f7..344f573 100644 --- a/lib/screens/metering/components/camera_container/provider_container_camera.dart +++ b/lib/screens/metering/components/camera_container/provider_container_camera.dart @@ -5,6 +5,7 @@ import 'package:lightmeter/data/models/film.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/widget_container_camera.dart'; import 'package:lightmeter/utils/inherited_generics.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; @@ -40,7 +41,7 @@ class CameraContainerProvider extends StatelessWidget { create: (context) => CameraContainerBloc( context.get(), context.read(), - ), + )..add(const RequestPermissionEvent()), child: CameraContainer( fastest: fastest, slowest: slowest, diff --git a/lib/screens/metering/components/camera_container/state_container_camera.dart b/lib/screens/metering/components/camera_container/state_container_camera.dart index b8e6173..4dfc02d 100644 --- a/lib/screens/metering/components/camera_container/state_container_camera.dart +++ b/lib/screens/metering/components/camera_container/state_container_camera.dart @@ -34,10 +34,42 @@ class CameraActiveState extends CameraContainerState { required this.exposureOffsetStep, required this.currentExposureOffset, }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is CameraActiveState && + other.zoomRange == zoomRange && + other.currentZoom == currentZoom && + other.exposureOffsetRange == exposureOffsetRange && + other.exposureOffsetStep == exposureOffsetStep && + other.currentExposureOffset == currentExposureOffset; + } + + @override + int get hashCode => Object.hash( + runtimeType, + zoomRange, + currentZoom, + exposureOffsetRange, + exposureOffsetStep, + currentExposureOffset, + ); } class CameraErrorState extends CameraContainerState { final CameraErrorType error; const CameraErrorState(this.error); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is CameraErrorState && other.error == error; + } + + @override + int get hashCode => Object.hash(error, runtimeType); } diff --git a/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart b/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart index 45719ec..d0e4031 100644 --- a/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart +++ b/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' @@ -16,35 +17,46 @@ class LightSensorContainerBloc final MeteringInteractor _meteringInteractor; StreamSubscription? _luxSubscriptions; - double _ev100 = 0.0; LightSensorContainerBloc( this._meteringInteractor, MeteringCommunicationBloc communicationBloc, ) : super( communicationBloc, - const LightSensorInitState(), - ); + const LightSensorContainerState(null), + ) { + on(_onLuxMeteringEvent); + } @override void onCommunicationState(communication_states.SourceState communicationState) { if (communicationState is communication_states.MeasureState) { if (_luxSubscriptions == null) { - _luxSubscriptions = _meteringInteractor.luxStream().listen((event) { - _ev100 = log2(event.toDouble() / 2.5) + _meteringInteractor.lightSensorEvCalibration; - communicationBloc.add(communication_event.MeteringInProgressEvent(_ev100)); - }); + _startMetering(); } else { - communicationBloc.add(communication_event.MeteringEndedEvent(_ev100)); - _luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null); + _cancelMetering(); } } } @override Future close() async { - communicationBloc.add(communication_event.MeteringEndedEvent(_ev100)); - _luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null); + _cancelMetering(); return super.close(); } + + void _onLuxMeteringEvent(LuxMeteringEvent event, Emitter emit) { + final ev100 = log2(event.lux.toDouble() / 2.5) + _meteringInteractor.lightSensorEvCalibration; + emit(LightSensorContainerState(ev100)); + communicationBloc.add(communication_event.MeteringInProgressEvent(ev100)); + } + + void _startMetering() { + _luxSubscriptions = _meteringInteractor.luxStream().listen((lux) => add(LuxMeteringEvent(lux))); + } + + void _cancelMetering() { + communicationBloc.add(communication_event.MeteringEndedEvent(state.ev100)); + _luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null); + } } diff --git a/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart b/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart index 6c6cbf6..8db83b3 100644 --- a/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart +++ b/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart @@ -1,3 +1,9 @@ abstract class LightSensorContainerEvent { const LightSensorContainerEvent(); } + +class LuxMeteringEvent extends LightSensorContainerEvent { + final int lux; + + const LuxMeteringEvent(this.lux); +} diff --git a/lib/screens/metering/components/light_sensor_container/state_container_light_sensor.dart b/lib/screens/metering/components/light_sensor_container/state_container_light_sensor.dart index 810be0c..346c8fb 100644 --- a/lib/screens/metering/components/light_sensor_container/state_container_light_sensor.dart +++ b/lib/screens/metering/components/light_sensor_container/state_container_light_sensor.dart @@ -1,7 +1,5 @@ -abstract class LightSensorContainerState { - const LightSensorContainerState(); -} +class LightSensorContainerState { + final double? ev100; -class LightSensorInitState extends LightSensorContainerState { - const LightSensorInitState(); + const LightSensorContainerState(this.ev100); } diff --git a/lib/screens/metering/components/shared/ev_source_base/bloc_base_ev_source.dart b/lib/screens/metering/components/shared/ev_source_base/bloc_base_ev_source.dart index 5d8f9c2..05c3fb1 100644 --- a/lib/screens/metering/components/shared/ev_source_base/bloc_base_ev_source.dart +++ b/lib/screens/metering/components/shared/ev_source_base/bloc_base_ev_source.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' @@ -21,5 +22,6 @@ abstract class EvSourceBlocBase extends Bloc { return super.close(); } + @visibleForTesting void onCommunicationState(communication_states.SourceState communicationState); } diff --git a/lib/screens/metering/event_metering.dart b/lib/screens/metering/event_metering.dart index 888bea1..e12ba35 100644 --- a/lib/screens/metering/event_metering.dart +++ b/lib/screens/metering/event_metering.dart @@ -1,16 +1,10 @@ import 'package:lightmeter/data/models/film.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; -abstract class MeteringEvent { +sealed class MeteringEvent { const MeteringEvent(); } -class StopTypeChangedEvent extends MeteringEvent { - final StopType stopType; - - const StopTypeChangedEvent(this.stopType); -} - class EquipmentProfileChangedEvent extends MeteringEvent { final EquipmentProfileData equipmentProfileData; @@ -18,9 +12,9 @@ class EquipmentProfileChangedEvent extends MeteringEvent { } class FilmChangedEvent extends MeteringEvent { - final Film data; + final Film film; - const FilmChangedEvent(this.data); + const FilmChangedEvent(this.film); } class IsoChangedEvent extends MeteringEvent { @@ -41,10 +35,13 @@ class MeasureEvent extends MeteringEvent { class MeasuredEvent extends MeteringEvent { final double ev100; + final bool isMetering; - const MeasuredEvent(this.ev100); + const MeasuredEvent(this.ev100, {required this.isMetering}); } class MeasureErrorEvent extends MeteringEvent { - const MeasureErrorEvent(); + final bool isMetering; + + const MeasureErrorEvent({required this.isMetering}); } diff --git a/lib/screens/metering/flow_metering.dart b/lib/screens/metering/flow_metering.dart index 3cd2f33..ac6cb0d 100644 --- a/lib/screens/metering/flow_metering.dart +++ b/lib/screens/metering/flow_metering.dart @@ -6,12 +6,10 @@ import 'package:lightmeter/data/light_sensor_service.dart'; import 'package:lightmeter/data/permissions_service.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; -import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/screens/metering/bloc_metering.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/screen_metering.dart'; import 'package:lightmeter/utils/inherited_generics.dart'; -import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringFlow extends StatefulWidget { const MeteringFlow({super.key}); @@ -36,10 +34,8 @@ class _MeteringFlowState extends State { BlocProvider(create: (_) => MeteringCommunicationBloc()), BlocProvider( create: (context) => MeteringBloc( - context.read(), context.get(), - context.get(), - context.get(), + context.read(), ), ), ], diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart index 29c7813..134d789 100644 --- a/lib/screens/metering/screen_metering.dart +++ b/lib/screens/metering/screen_metering.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/ev_source_type.dart'; @@ -29,9 +31,7 @@ class MeteringScreen extends StatelessWidget { Expanded( child: BlocBuilder( builder: (_, state) => _MeteringContainerBuidler( - fastest: state is MeteringDataState ? state.fastest : null, - slowest: state is MeteringDataState ? state.slowest : null, - exposurePairs: state is MeteringDataState ? state.exposurePairs : [], + ev: state is MeteringDataState ? state.ev : null, film: state.film, iso: state.iso, nd: state.nd, @@ -45,9 +45,7 @@ class MeteringScreen extends StatelessWidget { BlocBuilder( builder: (context, state) => MeteringBottomControlsProvider( ev: state is MeteringDataState ? state.ev : null, - isMetering: - state is LoadingState || state is MeteringDataState && state.continuousMetering, - hasError: state is MeteringDataState && state.hasError, + isMetering: state.isMetering, onSwitchEvSourceType: context.get().hasLightSensor ? EvSourceTypeProvider.of(context).toggleType : null, @@ -73,47 +71,41 @@ class _InheritedListeners extends StatelessWidget { onDidChangeDependencies: (value) { context.read().add(EquipmentProfileChangedEvent(value)); }, - child: InheritedWidgetListener( + child: InheritedModelAspectListener( + aspect: MeteringScreenLayoutFeature.filmPicker, onDidChangeDependencies: (value) { - context.read().add(StopTypeChangedEvent(value)); + if (!value) context.read().add(const FilmChangedEvent(Film.other())); }, - child: InheritedModelAspectListener( - aspect: MeteringScreenLayoutFeature.filmPicker, - onDidChangeDependencies: (value) { - if (!value) context.read().add(const FilmChangedEvent(Film.other())); - }, - child: child, - ), + child: child, ), ); } } class _MeteringContainerBuidler extends StatelessWidget { - final ExposurePair? fastest; - final ExposurePair? slowest; + final double? ev; final Film film; final IsoValue iso; final NdValue nd; final ValueChanged onFilmChanged; final ValueChanged onIsoChanged; final ValueChanged onNdChanged; - final List exposurePairs; const _MeteringContainerBuidler({ - required this.fastest, - required this.slowest, + required this.ev, required this.film, required this.iso, required this.nd, required this.onFilmChanged, required this.onIsoChanged, required this.onNdChanged, - required this.exposurePairs, }); @override Widget build(BuildContext context) { + final exposurePairs = ev != null ? buildExposureValues(context, ev!, film) : []; + final fastest = exposurePairs.isNotEmpty ? exposurePairs.first : null; + final slowest = exposurePairs.isNotEmpty ? exposurePairs.last : null; return context.listen() == EvSourceType.camera ? CameraContainerProvider( fastest: fastest, @@ -138,4 +130,68 @@ class _MeteringContainerBuidler extends StatelessWidget { exposurePairs: exposurePairs, ); } + + List buildExposureValues(BuildContext context, double ev, Film film) { + if (ev.isNaN || ev.isInfinite) { + return List.empty(); + } + + /// Depending on the `stopType` the exposure pairs list length is multiplied by 1,2 or 3 + final StopType stopType = context.listen(); + final int evSteps = (ev * (stopType.index + 1)).round(); + + final EquipmentProfile equipmentProfile = context.listen(); + final List apertureValues = + equipmentProfile.apertureValues.whereStopType(stopType); + final List shutterSpeedValues = + equipmentProfile.shutterSpeedValues.whereStopType(stopType); + + /// Basically we use 1" shutter speed as an anchor point for building the exposure pairs list. + /// But user can exclude this value from the list using custom equipment profile. + /// So we have to restore the index of the anchor value. + const ShutterSpeedValue anchorShutterSpeed = ShutterSpeedValue(1, false, StopType.full); + int anchorIndex = shutterSpeedValues.indexOf(anchorShutterSpeed); + if (anchorIndex < 0) { + final filteredFullList = ShutterSpeedValue.values.whereStopType(stopType); + final customListStartIndex = filteredFullList.indexOf(shutterSpeedValues.first); + final fullListAnchor = filteredFullList.indexOf(anchorShutterSpeed); + if (customListStartIndex < fullListAnchor) { + /// This means, that user excluded anchor value at the end, + /// i.e. all shutter speed values are shorter than 1". + anchorIndex = fullListAnchor - customListStartIndex; + } else { + /// In case user excludes anchor value at the start, + /// we can do no adjustment. + } + } + final int evOffset = anchorIndex - evSteps; + + late final int apertureOffset; + late final int shutterSpeedOffset; + if (evOffset >= 0) { + apertureOffset = 0; + shutterSpeedOffset = evOffset; + } else { + apertureOffset = -evOffset; + shutterSpeedOffset = 0; + } + + final int itemsCount = min( + apertureValues.length + shutterSpeedOffset, + shutterSpeedValues.length + apertureOffset, + ) - + max(apertureOffset, shutterSpeedOffset); + + if (itemsCount < 0) { + return List.empty(); + } + return List.generate( + itemsCount, + (index) => ExposurePair( + apertureValues[index + apertureOffset], + film.reciprocityFailure(shutterSpeedValues[index + shutterSpeedOffset]), + ), + growable: false, + ); + } } diff --git a/lib/screens/metering/state_metering.dart b/lib/screens/metering/state_metering.dart index 52dcf7a..909895d 100644 --- a/lib/screens/metering/state_metering.dart +++ b/lib/screens/metering/state_metering.dart @@ -1,18 +1,21 @@ import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/data/models/film.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; @immutable abstract class MeteringState { + final double? ev100; final Film film; final IsoValue iso; final NdValue nd; + final bool isMetering; const MeteringState({ + this.ev100, required this.film, required this.iso, required this.nd, + required this.isMetering, }); } @@ -21,24 +24,18 @@ class LoadingState extends MeteringState { required super.film, required super.iso, required super.nd, - }); + }) : super(isMetering: true); } class MeteringDataState extends MeteringState { - final double? ev; - final List exposurePairs; - final bool continuousMetering; - const MeteringDataState({ - required this.ev, + required super.ev100, required super.film, required super.iso, required super.nd, - required this.exposurePairs, - required this.continuousMetering, + required super.isMetering, }); - ExposurePair? get fastest => exposurePairs.isEmpty ? null : exposurePairs.first; - ExposurePair? get slowest => exposurePairs.isEmpty ? null : exposurePairs.last; + double? get ev => ev100 != null ? ev100! + log2(iso.value / 100) - nd.stopReduction : null; bool get hasError => ev == null; } diff --git a/pubspec.yaml b/pubspec.yaml index 361e254..3c2451d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: app_settings: 4.2.0 + bloc_concurrency: 0.2.2 camera: 0.10.5 clipboard: 0.1.3 dynamic_color: 1.6.5 @@ -16,7 +17,7 @@ dependencies: firebase_crashlytics: 3.3.1 flutter: sdk: flutter - flutter_bloc: 8.1.2 + flutter_bloc: 8.1.3 flutter_localizations: sdk: flutter intl: 0.18.0 @@ -35,10 +36,15 @@ dependencies: vibration: 1.7.7 dev_dependencies: + bloc_test: 9.1.3 + build_runner: ^2.1.7 flutter_launcher_icons: 0.11.0 flutter_native_splash: 2.2.16 + flutter_test: + sdk: flutter google_fonts: 3.0.1 lint: 2.1.2 + mocktail: 0.3.0 test: 1.24.1 flutter: diff --git a/test/screens/metering/bloc_metering_test.dart b/test/screens/metering/bloc_metering_test.dart new file mode 100644 index 0000000..7e87a27 --- /dev/null +++ b/test/screens/metering/bloc_metering_test.dart @@ -0,0 +1,609 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:lightmeter/data/models/film.dart'; +import 'package:lightmeter/interactors/metering_interactor.dart'; +import 'package:lightmeter/screens/metering/bloc_metering.dart'; +import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' + as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' + as communication_states; +import 'package:lightmeter/screens/metering/event_metering.dart'; +import 'package:lightmeter/screens/metering/state_metering.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockMeteringCommunicationBloc extends MockBloc< + communication_events.MeteringCommunicationEvent, + communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} + +class _MockMeteringInteractor extends Mock implements MeteringInteractor {} + +void main() { + late _MockMeteringInteractor meteringInteractor; + late _MockMeteringCommunicationBloc communicationBloc; + late MeteringBloc bloc; + const iso100 = IsoValue(100, StopType.full); + + setUp(() { + meteringInteractor = _MockMeteringInteractor(); + when(() => meteringInteractor.iso).thenReturn(iso100); + when(() => meteringInteractor.ndFilter).thenReturn(NdValue.values.first); + when(() => meteringInteractor.film).thenReturn(Film.values.first); + when(meteringInteractor.quickVibration).thenAnswer((_) async {}); + when(meteringInteractor.responseVibration).thenAnswer((_) async {}); + when(meteringInteractor.errorVibration).thenAnswer((_) async {}); + + communicationBloc = _MockMeteringCommunicationBloc(); + + bloc = MeteringBloc( + meteringInteractor, + communicationBloc, + ); + }); + + tearDown(() { + bloc.close(); + communicationBloc.close(); + }); + + group( + '`MeasureEvent`', + () { + blocTest( + '`MeasureEvent` -> success', + build: () => bloc, + act: (bloc) async { + bloc.add(const MeasureEvent()); + bloc.onCommunicationState(const communication_states.MeteringEndedState(2)); + }, + verify: (_) { + verify(() => meteringInteractor.quickVibration()).called(1); + verify(() => communicationBloc.add(const communication_events.MeasureEvent())).called(1); + verify(() => meteringInteractor.responseVibration()).called(1); + }, + expect: () => [ + isA(), + isA() + .having((state) => state.isMetering, 'isMetering', false) + .having((state) => state.ev, 'ev', 2), + ], + ); + + blocTest( + '`MeasureEvent` -> error', + build: () => bloc, + act: (bloc) async { + bloc.add(const MeasureEvent()); + bloc.onCommunicationState(const communication_states.MeteringEndedState(null)); + }, + verify: (_) { + verify(() => meteringInteractor.quickVibration()).called(1); + verify(() => communicationBloc.add(const communication_events.MeasureEvent())).called(1); + verify(() => meteringInteractor.errorVibration()).called(1); + }, + expect: () => [ + isA(), + isA() + .having((state) => state.isMetering, 'isMetering', false) + .having((state) => state.ev100, 'ev100', null) + .having((state) => state.ev, 'ev', null), + ], + ); + + blocTest( + '`MeasureEvent` -> continuous metering', + build: () => bloc, + act: (bloc) async { + // delays here simulate light sensor behaviour + // when sensor does not fire new LUX events when value is not changed + bloc.add(const MeasureEvent()); + bloc.onCommunicationState(const communication_states.MeteringInProgressState(null)); + await Future.delayed(const Duration(seconds: 1)); + bloc.onCommunicationState(const communication_states.MeteringInProgressState(2)); + bloc.onCommunicationState(const communication_states.MeteringInProgressState(5.5)); + await Future.delayed(const Duration(seconds: 2)); + bloc.onCommunicationState(const communication_states.MeteringInProgressState(null)); + bloc.onCommunicationState(const communication_states.MeteringInProgressState(4)); + bloc.add(const MeasureEvent()); + bloc.onCommunicationState(const communication_states.MeteringEndedState(4)); + }, + verify: (_) { + verify(() => meteringInteractor.quickVibration()).called(2); + verify(() => communicationBloc.add(const communication_events.MeasureEvent())).called(2); + verify(() => meteringInteractor.responseVibration()).called(4); + verify(() => meteringInteractor.errorVibration()).called(2); + }, + expect: () => [ + isA(), + isA() + .having((state) => state.isMetering, 'isMetering', true) + .having((state) => state.ev, 'ev', null), + isA() + .having((state) => state.isMetering, 'isMetering', true) + .having((state) => state.ev, 'ev', 2), + isA() + .having((state) => state.isMetering, 'isMetering', true) + .having((state) => state.ev, 'ev', 5.5), + isA() + .having((state) => state.isMetering, 'isMetering', true) + .having((state) => state.ev, 'ev', null), + isA() + .having((state) => state.isMetering, 'isMetering', true) + .having((state) => state.ev, 'ev', 4), + isA(), + isA() + .having((state) => state.isMetering, 'isMetering', false) + .having((state) => state.ev, 'ev', 4), + ], + ); + }, + timeout: const Timeout(Duration(seconds: 4)), + ); + + group( + '`IsoChangedEvent`', + () { + blocTest( + 'Pick different ISO (ev100 != null)', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const IsoChangedEvent(IsoValue(200, StopType.full))); + }, + verify: (_) { + verify(() => meteringInteractor.film = Film.values.first).called(1); + verify(() => meteringInteractor.iso = const IsoValue(200, StopType.full)).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.ev, 'ev', 2.0) + .having((state) => state.film, 'film', Film.values.first) + .having((state) => state.iso, 'iso', const IsoValue(200, StopType.full)) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'Pick different ISO (ev100 = null)', + build: () => bloc, + seed: () => MeteringDataState( + ev100: null, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const IsoChangedEvent(IsoValue(200, StopType.full))); + }, + verify: (_) { + verify(() => meteringInteractor.film = Film.values.first).called(1); + verify(() => meteringInteractor.iso = const IsoValue(200, StopType.full)).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', null) + .having((state) => state.ev, 'ev', null) + .having((state) => state.film, 'film', Film.values.first) + .having((state) => state.iso, 'iso', const IsoValue(200, StopType.full)) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'Pick same ISO', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const IsoChangedEvent(IsoValue(100, StopType.full))); + }, + verify: (_) { + verify(() => meteringInteractor.film = Film.values.first).called(1); + verifyNever(() => meteringInteractor.iso = const IsoValue(100, StopType.full)); + }, + expect: () => [], + ); + + blocTest( + 'Pick different ISO & measure', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const IsoChangedEvent(IsoValue(200, StopType.full))); + bloc.add(const MeasureEvent()); + bloc.onCommunicationState(const communication_states.MeteringEndedState(2)); + }, + verify: (_) { + verify(() => meteringInteractor.film = Film.values.first).called(1); + verify(() => meteringInteractor.iso = const IsoValue(200, StopType.full)).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.ev, 'ev', 2.0) + .having((state) => state.film, 'film', Film.values.first) + .having((state) => state.iso, 'iso', const IsoValue(200, StopType.full)) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + isA(), + isA() + .having((state) => state.ev100, 'ev100', 2.0) + .having((state) => state.ev, 'ev', 3.0) + .having((state) => state.film, 'film', Film.values.first) + .having((state) => state.iso, 'iso', const IsoValue(200, StopType.full)) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + }, + ); + + group( + '`NdChangedEvent`', + () { + blocTest( + 'Pick different ND (ev100 != null)', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const NdChangedEvent(NdValue(2))); + }, + verify: (_) { + verify(() => meteringInteractor.ndFilter = const NdValue(2)).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.ev, 'ev', 0.0) + .having((state) => state.film, 'film', Film.values[1]) + .having((state) => state.iso, 'iso', const IsoValue(100, StopType.full)) + .having((state) => state.nd, 'nd', const NdValue(2)) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'Pick different ND (ev100 = null)', + build: () => bloc, + seed: () => MeteringDataState( + ev100: null, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const NdChangedEvent(NdValue(2))); + }, + verify: (_) { + verify(() => meteringInteractor.ndFilter = const NdValue(2)).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', null) + .having((state) => state.ev, 'ev', null) + .having((state) => state.film, 'film', Film.values[1]) + .having((state) => state.iso, 'iso', const IsoValue(100, StopType.full)) + .having((state) => state.nd, 'nd', const NdValue(2)) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'Pick same ND', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(NdChangedEvent(NdValue.values.first)); + }, + verify: (_) { + verifyNever(() => meteringInteractor.ndFilter = NdValue.values.first); + }, + expect: () => [], + ); + + blocTest( + 'Pick different ND & measure', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const NdChangedEvent(NdValue(2))); + bloc.add(const MeasureEvent()); + bloc.onCommunicationState(const communication_states.MeteringEndedState(2)); + }, + verify: (_) { + verify(() => meteringInteractor.ndFilter = const NdValue(2)).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.ev, 'ev', 0.0) + .having((state) => state.film, 'film', Film.values[1]) + .having((state) => state.iso, 'iso', const IsoValue(100, StopType.full)) + .having((state) => state.nd, 'nd', const NdValue(2)) + .having((state) => state.isMetering, 'isMetering', false), + isA(), + isA() + .having((state) => state.ev100, 'ev100', 2.0) + .having((state) => state.ev, 'ev', 1.0) + .having((state) => state.film, 'film', Film.values[1]) + .having((state) => state.iso, 'iso', const IsoValue(100, StopType.full)) + .having((state) => state.nd, 'nd', const NdValue(2)) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + }, + ); + + group( + '`FilmChangedEvent`', + () { + blocTest( + 'Pick different film with different ISO', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: const FomapanFilm.creative100(), + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const FilmChangedEvent(FomapanFilm.creative200())); + }, + verify: (_) { + verify(() => meteringInteractor.film = const FomapanFilm.creative200()).called(1); + verify(() => meteringInteractor.iso = const IsoValue(200, StopType.full)).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.ev, 'ev', 2.0) + .having((state) => state.film, 'film', const FomapanFilm.creative200()) + .having((state) => state.iso, 'iso', const IsoValue(200, StopType.full)) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'Pick different film with same ISO', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: const FomapanFilm.creative100(), + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const FilmChangedEvent(IlfordFilm.delta100())); + }, + verify: (_) { + verify(() => meteringInteractor.film = const IlfordFilm.delta100()).called(1); + verifyNever(() => meteringInteractor.iso = const IsoValue(100, StopType.full)); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.ev, 'ev', 1.0) + .having((state) => state.film, 'film', const IlfordFilm.delta100()) + .having((state) => state.iso, 'iso', const IsoValue(100, StopType.full)) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'Pick same film', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: const FomapanFilm.creative100(), + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const FilmChangedEvent(FomapanFilm.creative100())); + }, + verify: (_) { + verifyNever(() => meteringInteractor.film = const FomapanFilm.creative100()); + }, + expect: () => [], + ); + + blocTest( + 'Pick `Film.other()`', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: const FomapanFilm.creative100(), + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(const FilmChangedEvent(Film.other())); + }, + verify: (_) { + verify(() => meteringInteractor.film = const Film.other()).called(1); + verifyNever(() => meteringInteractor.iso = const IsoValue(0, StopType.full)); + verifyNever(() => meteringInteractor.responseVibration()); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.ev, 'ev', 1.0) + .having((state) => state.film, 'film', const Film.other()) + .having((state) => state.iso, 'iso', const IsoValue(100, StopType.full)) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + }, + ); + + group( + '`EquipmentProfileChangedEvent`', + () { + final reducedProfile = EquipmentProfileData( + id: '0', + name: 'Reduced', + apertureValues: ApertureValue.values, + ndValues: NdValue.values.getRange(0, 3).toList(), + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values.getRange(4, 23).toList(), + ); + + blocTest( + 'New profile has current ISO & ND', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(EquipmentProfileChangedEvent(reducedProfile)); + }, + verify: (_) { + verifyNever(() => meteringInteractor.film = const Film.other()); + verifyNever(() => meteringInteractor.iso = reducedProfile.isoValues.first); + verifyNever(() => meteringInteractor.ndFilter = reducedProfile.ndValues.first); + verifyNever(() => meteringInteractor.responseVibration()); + }, + expect: () => [], + ); + + blocTest( + 'New profile has new ISO & current ND', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: IsoValue.values[2], + nd: NdValue.values.first, + isMetering: false, + ), + act: (bloc) async { + bloc.add(EquipmentProfileChangedEvent(reducedProfile)); + }, + verify: (_) { + verify(() => meteringInteractor.film = const Film.other()).called(1); + verify(() => meteringInteractor.iso = reducedProfile.isoValues.first).called(1); + verifyNever(() => meteringInteractor.ndFilter = reducedProfile.ndValues.first); + verify(() => meteringInteractor.responseVibration()).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.film, 'film', const Film.other()) + .having((state) => state.iso, 'iso', reducedProfile.isoValues.first) + .having((state) => state.nd, 'nd', NdValue.values.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'New profile has current ISO & new ND', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: const IsoValue(100, StopType.full), + nd: NdValue.values[4], + isMetering: false, + ), + act: (bloc) async { + bloc.add(EquipmentProfileChangedEvent(reducedProfile)); + }, + verify: (_) { + verifyNever(() => meteringInteractor.film = const Film.other()); + verifyNever(() => meteringInteractor.iso = reducedProfile.isoValues.first); + verify(() => meteringInteractor.ndFilter = reducedProfile.ndValues.first).called(1); + verify(() => meteringInteractor.responseVibration()).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.film, 'film', Film.values[1]) + .having((state) => state.iso, 'iso', const IsoValue(100, StopType.full)) + .having((state) => state.nd, 'nd', reducedProfile.ndValues.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + + blocTest( + 'New profile has new ISO & new ND', + build: () => bloc, + seed: () => MeteringDataState( + ev100: 1.0, + film: Film.values[1], + iso: IsoValue.values[2], + nd: NdValue.values[4], + isMetering: false, + ), + act: (bloc) async { + bloc.add(EquipmentProfileChangedEvent(reducedProfile)); + }, + verify: (_) { + verify(() => meteringInteractor.film = const Film.other()).called(1); + verify(() => meteringInteractor.iso = reducedProfile.isoValues.first).called(1); + verify(() => meteringInteractor.ndFilter = reducedProfile.ndValues.first).called(1); + verify(() => meteringInteractor.responseVibration()).called(1); + }, + expect: () => [ + isA() + .having((state) => state.ev100, 'ev100', 1.0) + .having((state) => state.film, 'film', const Film.other()) + .having((state) => state.iso, 'iso', reducedProfile.isoValues.first) + .having((state) => state.nd, 'nd', reducedProfile.ndValues.first) + .having((state) => state.isMetering, 'isMetering', false), + ], + ); + }, + ); +} diff --git a/test/screens/metering/communication/bloc_communication_metering_test.dart b/test/screens/metering/communication/bloc_communication_metering_test.dart new file mode 100644 index 0000000..604de75 --- /dev/null +++ b/test/screens/metering/communication/bloc_communication_metering_test.dart @@ -0,0 +1,101 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart'; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart'; +import 'package:test/test.dart'; + +void main() { + late MeteringCommunicationBloc bloc; + + setUp(() { + bloc = MeteringCommunicationBloc(); + }); + + tearDown(() { + bloc.close(); + }); + + group( + '`MeasureEvent`', + () { + blocTest( + 'Multiple consequtive measure events', + build: () => bloc, + act: (bloc) async { + bloc.add(const MeasureEvent()); + bloc.add(const MeasureEvent()); + bloc.add(const MeasureEvent()); + bloc.add(const MeasureEvent()); + }, + expect: () => [ + isA(), + isA(), + isA(), + isA(), + ], + ); + + blocTest( + 'Continuous metering simulation', + build: () => bloc, + act: (bloc) async { + bloc.add(const MeasureEvent()); + bloc.add(const MeteringInProgressEvent(1)); + bloc.add(const MeteringInProgressEvent(null)); + bloc.add(const MeteringInProgressEvent(null)); + bloc.add(const MeteringInProgressEvent(2)); + bloc.add(const MeasureEvent()); + bloc.add(const MeteringEndedEvent(2)); + bloc.add(const MeteringEndedEvent(2)); + }, + expect: () => [ + isA(), + isA().having((state) => state.ev100, 'ev100', 1), + isA().having((state) => state.ev100, 'ev100', null), + isA().having((state) => state.ev100, 'ev100', 2), + isA(), + isA().having((state) => state.ev100, 'ev100', 2), + ], + ); + }, + ); + + group( + '`MeteringInProgressEvent`', + () { + blocTest( + 'Multiple consequtive in progress events', + build: () => bloc, + act: (bloc) async { + bloc.add(const MeteringInProgressEvent(1)); + bloc.add(const MeteringInProgressEvent(1)); + bloc.add(const MeteringInProgressEvent(1)); + bloc.add(const MeteringInProgressEvent(null)); + bloc.add(const MeteringInProgressEvent(null)); + bloc.add(const MeteringInProgressEvent(2)); + }, + expect: () => [ + isA().having((state) => state.ev100, 'ev100', 1), + isA().having((state) => state.ev100, 'ev100', null), + isA().having((state) => state.ev100, 'ev100', 2), + ], + ); + }, + ); + + group( + '`MeteringEndedEvent`', + () { + blocTest( + 'Multiple consequtive ended events', + build: () => bloc, + act: (bloc) async { + bloc.add(const MeteringEndedEvent(1)); + }, + expect: () => [ + isA().having((state) => state.ev100, 'ev100', 1), + ], + ); + }, + ); +} diff --git a/test/screens/metering/communication/event_communication_metering_test.dart b/test/screens/metering/communication/event_communication_metering_test.dart new file mode 100644 index 0000000..baec1bc --- /dev/null +++ b/test/screens/metering/communication/event_communication_metering_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart'; +import 'package:test/test.dart'; + +void main() { + group( + '`MeteringInProgressEvent`', + () { + final a = MeteringInProgressEvent(1.0); + final b = MeteringInProgressEvent(1.0); + final c = MeteringInProgressEvent(2.0); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); + + group( + '`MeteringEndedEvent`', + () { + final a = MeteringEndedEvent(1.0); + final b = MeteringEndedEvent(1.0); + final c = MeteringEndedEvent(2.0); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); +} diff --git a/test/screens/metering/communication/state_communication_metering_test.dart b/test/screens/metering/communication/state_communication_metering_test.dart new file mode 100644 index 0000000..7076aab --- /dev/null +++ b/test/screens/metering/communication/state_communication_metering_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart'; +import 'package:test/test.dart'; + +void main() { + group( + '`MeteringInProgressState`', + () { + final a = MeteringInProgressState(1.0); + final b = MeteringInProgressState(1.0); + final c = MeteringInProgressState(2.0); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); + + group( + '`MeteringEndedState`', + () { + final a = MeteringEndedState(1.0); + final b = MeteringEndedState(1.0); + final c = MeteringEndedState(2.0); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); +} diff --git a/test/screens/metering/components/camera/bloc_container_camera_test.dart b/test/screens/metering/components/camera/bloc_container_camera_test.dart new file mode 100644 index 0000000..e720ef5 --- /dev/null +++ b/test/screens/metering/components/camera/bloc_container_camera_test.dart @@ -0,0 +1,490 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/interactors/metering_interactor.dart'; +import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' + as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' + as communication_states; +import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockMeteringCommunicationBloc extends MockBloc< + communication_events.MeteringCommunicationEvent, + communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} + +class _MockMeteringInteractor extends Mock implements MeteringInteractor {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockMeteringInteractor meteringInteractor; + late _MockMeteringCommunicationBloc communicationBloc; + late CameraContainerBloc bloc; + + const cameraMethodChannel = MethodChannel('plugins.flutter.io/camera'); + const cameraIdMethodChannel = MethodChannel('flutter.io/cameraPlugin/camera1'); + const availableCameras = [ + { + "name": "front", + "lensFacing": "front", + "sensorOrientation": 0, + }, + { + "name": "back", + "lensFacing": "back", + "sensorOrientation": 0, + }, + ]; + const frontCameras = [ + { + "name": "front", + "lensFacing": "front", + "sensorOrientation": 0, + }, + { + "name": "front2", + "lensFacing": "front", + "sensorOrientation": 0, + }, + ]; + Future? cameraMethodCallSuccessHandler( + MethodCall methodCall, { + List> cameras = availableCameras, + }) async { + switch (methodCall.method) { + case "availableCameras": + return cameras; + case "create": + return {"cameraId": 1}; + case "initialize": + await cameraIdMethodChannel.invokeMockMethod("initialized", { + 'cameraId': 1, + 'previewWidth': 2160.0, + 'previewHeight': 3840.0, + 'exposureMode': 'auto', + 'exposurePointSupported': true, + 'focusMode': 'auto', + 'focusPointSupported': true, + }); + return {}; + case "setFlashMode": + return null; + case "getMinZoomLevel": + return 0.67; + case "getMaxZoomLevel": + return 7.0; + case "getMinExposureOffset": + return -4.0; + case "getMaxExposureOffset": + return 4.0; + case "getExposureOffsetStepSize": + return 0.1666666; + case "takePicture": + return ""; + case "setExposureOffset": + // ignore: avoid_dynamic_calls + return methodCall.arguments["offset"]; + default: + return null; + } + } + + final initializedStateSequence = [ + isA(), + isA(), + isA() + .having((state) => state.zoomRange, 'zoomRange', const RangeValues(1.0, 7.0)) + .having((state) => state.currentZoom, 'currentZoom', 1.0) + .having( + (state) => state.exposureOffsetRange, + 'exposureOffsetRange', + const RangeValues(-4.0, 4.0), + ) + .having((state) => state.exposureOffsetStep, 'exposureOffsetStep', 0.1666666) + .having((state) => state.currentExposureOffset, 'currentExposureOffset', 0.0), + ]; + + setUpAll(() { + meteringInteractor = _MockMeteringInteractor(); + communicationBloc = _MockMeteringCommunicationBloc(); + + when(() => meteringInteractor.cameraEvCalibration).thenReturn(0.0); + when(meteringInteractor.quickVibration).thenAnswer((_) async {}); + }); + + setUp(() { + bloc = CameraContainerBloc( + meteringInteractor, + communicationBloc, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(cameraMethodChannel, cameraMethodCallSuccessHandler); + }); + + tearDown(() { + bloc.close(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(cameraMethodChannel, null); + }); + + group( + '`RequestPermissionEvent`', + () { + blocTest( + 'Request denied', + build: () => bloc, + setUp: () { + when(() => meteringInteractor.requestPermission()).thenAnswer((_) async => false); + }, + act: (bloc) => bloc.add(const RequestPermissionEvent()), + verify: (_) { + verify(() => meteringInteractor.requestPermission()).called(1); + }, + expect: () => [ + isA() + .having((state) => state.error, "error", CameraErrorType.permissionNotGranted), + ], + ); + + blocTest( + 'Request granted -> check denied', + build: () => bloc, + setUp: () { + when(() => meteringInteractor.requestPermission()).thenAnswer((_) async => true); + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => false); + }, + act: (bloc) => bloc.add(const RequestPermissionEvent()), + verify: (_) { + verify(() => meteringInteractor.requestPermission()).called(1); + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => [ + isA(), + isA() + .having((state) => state.error, "error", CameraErrorType.permissionNotGranted), + ], + ); + + blocTest( + 'Request granted -> check granted', + build: () => bloc, + setUp: () { + when(() => meteringInteractor.requestPermission()).thenAnswer((_) async => true); + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + act: (bloc) => bloc.add(const RequestPermissionEvent()), + verify: (_) { + verify(() => meteringInteractor.requestPermission()).called(1); + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => initializedStateSequence, + ); + }, + ); + + group( + '`OpenAppSettingsEvent`', + () { + blocTest( + 'App settings opened', + setUp: () { + when(() => meteringInteractor.openAppSettings()).thenAnswer((_) {}); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const OpenAppSettingsEvent()); + }, + verify: (_) { + verify(() => meteringInteractor.openAppSettings()).called(1); + }, + expect: () => [], + ); + }, + ); + + group( + '`InitializeEvent`/`DeinitializeEvent`', + () { + blocTest( + 'No cameras detected error', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + cameraMethodChannel, + (methodCall) async => cameraMethodCallSuccessHandler(methodCall, cameras: const []), + ); + }, + tearDown: () { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(cameraMethodChannel, null); + }, + build: () => bloc, + act: (bloc) => bloc.add(const InitializeEvent()), + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => [ + isA(), + isA() + .having((state) => state.error, "error", CameraErrorType.noCamerasDetected), + ], + ); + + blocTest( + 'No back facing cameras available', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + cameraMethodChannel, + (methodCall) async => cameraMethodCallSuccessHandler(methodCall, cameras: frontCameras), + ); + }, + tearDown: () { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(cameraMethodChannel, null); + }, + build: () => bloc, + act: (bloc) => bloc.add(const InitializeEvent()), + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => initializedStateSequence, + ); + + blocTest( + 'Catch other initialization errors', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + cameraMethodChannel, + (methodCall) async { + switch (methodCall.method) { + case "availableCameras": + return availableCameras; + default: + return null; + } + }, + ); + }, + tearDown: () { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(cameraMethodChannel, null); + }, + build: () => bloc, + act: (bloc) => bloc.add(const InitializeEvent()), + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => [ + isA(), + isA().having((state) => state.error, "error", CameraErrorType.other), + ], + ); + + blocTest( + 'appLifecycleStateObserver', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const InitializeEvent()); + await Future.delayed(Duration.zero); + TestWidgetsFlutterBinding.instance + .handleAppLifecycleStateChanged(AppLifecycleState.detached); + TestWidgetsFlutterBinding.instance + .handleAppLifecycleStateChanged(AppLifecycleState.resumed); + }, + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(2); + }, + expect: () => [ + ...initializedStateSequence, + ...initializedStateSequence, + ], + ); + }, + ); + + group( + '`_takePicture()`', + () { + blocTest( + 'Returned ev100 == null', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const InitializeEvent()); + await Future.delayed(Duration.zero); + bloc.onCommunicationState(const communication_states.MeasureState()); + bloc.onCommunicationState(const communication_states.MeasureState()); + bloc.onCommunicationState(const communication_states.MeasureState()); + bloc.onCommunicationState(const communication_states.MeasureState()); + }, + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + verifyNever(() => meteringInteractor.cameraEvCalibration); + }, + expect: () => initializedStateSequence, + ); + + // TODO(vodemn): figure out how to mock `_file.readAsBytes()` + // blocTest( + // 'Returned non-null ev100', + // setUp: () { + // when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + // TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + // .setMockMethodCallHandler(cameraMethodChannel, cameraMethodCallSuccessHandler); + // }, + // tearDown: () { + // TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + // .setMockMethodCallHandler(cameraMethodChannel, null); + // }, + // build: () => bloc, + // act: (bloc) async { + // bloc.add(const InitializeEvent()); + // await Future.delayed(Duration.zero); + // bloc.onCommunicationState(const communication_states.MeasureState()); + // }, + // verify: (_) { + // verify(() => meteringInteractor.checkCameraPermission()).called(1); + // verifyNever(() => meteringInteractor.cameraEvCalibration); + // verify(() { + // communicationBloc.add(const communication_events.MeteringEndedEvent(null)); + // }).called(2); + // }, + // expect: () => [ + // ...initializedStateSequence, + // ], + // ); + }, + ); + + group( + '`ZoomChangedEvent`', + () { + blocTest( + 'Set zoom multiple times', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const InitializeEvent()); + await Future.delayed(Duration.zero); + bloc.add(const ZoomChangedEvent(2.0)); + bloc.add(const ZoomChangedEvent(2.0)); + bloc.add(const ZoomChangedEvent(2.0)); + bloc.add(const ZoomChangedEvent(3.0)); + }, + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => [ + ...initializedStateSequence, + isA() + .having((state) => state.zoomRange, 'zoomRange', const RangeValues(1.0, 7.0)) + .having((state) => state.currentZoom, 'currentZoom', 2.0) + .having( + (state) => state.exposureOffsetRange, + 'exposureOffsetRange', + const RangeValues(-4.0, 4.0), + ) + .having((state) => state.exposureOffsetStep, 'exposureOffsetStep', 0.1666666) + .having((state) => state.currentExposureOffset, 'currentExposureOffset', 0.0), + isA() + .having((state) => state.zoomRange, 'zoomRange', const RangeValues(1.0, 7.0)) + .having((state) => state.currentZoom, 'currentZoom', 3.0) + .having( + (state) => state.exposureOffsetRange, + 'exposureOffsetRange', + const RangeValues(-4.0, 4.0), + ) + .having((state) => state.exposureOffsetStep, 'exposureOffsetStep', 0.1666666) + .having((state) => state.currentExposureOffset, 'currentExposureOffset', 0.0), + ], + ); + }, + ); + + group( + '`ExposureOffsetChangedEvent`/`ExposureOffsetResetEvent`', + () { + blocTest( + 'Set exposure offset multiple times and reset', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const InitializeEvent()); + await Future.delayed(Duration.zero); + bloc.add(const ExposureOffsetChangedEvent(2.0)); + bloc.add(const ExposureOffsetChangedEvent(2.0)); + bloc.add(const ExposureOffsetChangedEvent(2.0)); + bloc.add(const ExposureOffsetChangedEvent(3.0)); + bloc.add(const ExposureOffsetResetEvent()); + }, + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => [ + ...initializedStateSequence, + isA() + .having((state) => state.zoomRange, 'zoomRange', const RangeValues(1.0, 7.0)) + .having((state) => state.currentZoom, 'currentZoom', 1.0) + .having( + (state) => state.exposureOffsetRange, + 'exposureOffsetRange', + const RangeValues(-4.0, 4.0), + ) + .having((state) => state.exposureOffsetStep, 'exposureOffsetStep', 0.1666666) + .having((state) => state.currentExposureOffset, 'currentExposureOffset', 2.0), + isA() + .having((state) => state.zoomRange, 'zoomRange', const RangeValues(1.0, 7.0)) + .having((state) => state.currentZoom, 'currentZoom', 1.0) + .having( + (state) => state.exposureOffsetRange, + 'exposureOffsetRange', + const RangeValues(-4.0, 4.0), + ) + .having((state) => state.exposureOffsetStep, 'exposureOffsetStep', 0.1666666) + .having((state) => state.currentExposureOffset, 'currentExposureOffset', 3.0), + isA() + .having((state) => state.zoomRange, 'zoomRange', const RangeValues(1.0, 7.0)) + .having((state) => state.currentZoom, 'currentZoom', 1.0) + .having( + (state) => state.exposureOffsetRange, + 'exposureOffsetRange', + const RangeValues(-4.0, 4.0), + ) + .having((state) => state.exposureOffsetStep, 'exposureOffsetStep', 0.1666666) + .having((state) => state.currentExposureOffset, 'currentExposureOffset', 0.0), + ], + ); + }, + ); +} + +extension _MethodChannelMock on MethodChannel { + Future invokeMockMethod(String method, dynamic arguments) async { + final data = const StandardMethodCodec().encodeMethodCall(MethodCall(method, arguments)); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + name, + data, + (ByteData? data) {}, + ); + } +} diff --git a/test/screens/metering/components/camera/event_container_camera_test.dart b/test/screens/metering/components/camera/event_container_camera_test.dart new file mode 100644 index 0000000..f136b5f --- /dev/null +++ b/test/screens/metering/components/camera/event_container_camera_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; +import 'package:test/test.dart'; + +void main() { + group( + '`ZoomChangedEvent`', + () { + final a = ZoomChangedEvent(1.0); + final b = ZoomChangedEvent(1.0); + final c = ZoomChangedEvent(2.0); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); + + group( + '`ExposureOffsetChangedEvent`', + () { + final a = ExposureOffsetChangedEvent(1.0); + final b = ExposureOffsetChangedEvent(1.0); + final c = ExposureOffsetChangedEvent(2.0); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); +} diff --git a/test/screens/metering/components/camera/state_container_camera_test.dart b/test/screens/metering/components/camera/state_container_camera_test.dart new file mode 100644 index 0000000..91a3eae --- /dev/null +++ b/test/screens/metering/components/camera/state_container_camera_test.dart @@ -0,0 +1,64 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart'; +import 'package:test/test.dart'; + +void main() { + group( + '`CameraActiveState`', + () { + final a = CameraActiveState( + zoomRange: RangeValues(1, 7), + currentZoom: 1, + exposureOffsetRange: RangeValues(-4, 4), + currentExposureOffset: 0, + exposureOffsetStep: 0.167, + ); + final b = CameraActiveState( + zoomRange: RangeValues(1, 7), + currentZoom: 1, + exposureOffsetRange: RangeValues(-4, 4), + currentExposureOffset: 0, + exposureOffsetStep: 0.167, + ); + final c = CameraActiveState( + zoomRange: RangeValues(1, 4), + currentZoom: 3, + exposureOffsetRange: RangeValues(-2, 2), + currentExposureOffset: 0, + exposureOffsetStep: 0.167, + ); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); + + group( + '`CameraErrorState`', + () { + final a = CameraErrorState(CameraErrorType.noCamerasDetected); + final b = CameraErrorState(CameraErrorType.noCamerasDetected); + final c = CameraErrorState(CameraErrorType.other); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); +} diff --git a/test/screens/metering/components/light_sensor/bloc_container_light_sensor_test.dart b/test/screens/metering/components/light_sensor/bloc_container_light_sensor_test.dart new file mode 100644 index 0000000..f60a79e --- /dev/null +++ b/test/screens/metering/components/light_sensor/bloc_container_light_sensor_test.dart @@ -0,0 +1,81 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:lightmeter/interactors/metering_interactor.dart'; +import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' + as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' + as communication_states; +import 'package:lightmeter/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart'; +import 'package:lightmeter/screens/metering/components/light_sensor_container/state_container_light_sensor.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockMeteringCommunicationBloc extends MockBloc< + communication_events.MeteringCommunicationEvent, + communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} + +class _MockMeteringInteractor extends Mock implements MeteringInteractor {} + +void main() { + late _MockMeteringInteractor meteringInteractor; + late _MockMeteringCommunicationBloc communicationBloc; + late LightSensorContainerBloc bloc; + + setUpAll(() { + meteringInteractor = _MockMeteringInteractor(); + communicationBloc = _MockMeteringCommunicationBloc(); + }); + + setUp(() { + bloc = LightSensorContainerBloc( + meteringInteractor, + communicationBloc, + ); + }); + + tearDown(() { + bloc.close(); + }); + + group( + '`LuxMeteringEvent`', + () { + const List luxIterable = [1, 2, 2, 2, 3]; + final List resultList = luxIterable.map((lux) => log2(lux / 2.5)).toList(); + blocTest( + 'Turn measuring on/off', + build: () => bloc, + setUp: () { + when(() => meteringInteractor.luxStream()) + .thenAnswer((_) => Stream.fromIterable(luxIterable)); + when(() => meteringInteractor.lightSensorEvCalibration).thenReturn(0.0); + }, + act: (bloc) async { + bloc.onCommunicationState(const communication_states.MeasureState()); + await Future.delayed(Duration.zero); + bloc.onCommunicationState(const communication_states.MeasureState()); + }, + verify: (_) { + verify(() => meteringInteractor.luxStream().listen((_) {})).called(1); + verify(() => meteringInteractor.lightSensorEvCalibration).called(5); + verify(() { + communicationBloc.add(communication_events.MeteringInProgressEvent(resultList.first)); + }).called(1); + verify(() { + communicationBloc.add(communication_events.MeteringInProgressEvent(resultList[1])); + }).called(3); + verify(() { + communicationBloc.add(communication_events.MeteringInProgressEvent(resultList.last)); + }).called(1); + verify(() { + communicationBloc.add(communication_events.MeteringEndedEvent(resultList.last)); + }).called(2); // +1 from dispose + }, + expect: () => resultList.map( + (e) => isA().having((state) => state.ev100, 'ev100', e), + ), + ); + }, + ); +} diff --git a/test_coverage.sh b/test_coverage.sh new file mode 100644 index 0000000..c88e397 --- /dev/null +++ b/test_coverage.sh @@ -0,0 +1,4 @@ +flutter test --coverage +lcov --remove coverage/lcov.info 'lib/generated/*' 'lib/l10n/*' -o coverage/new_lcov.info +genhtml coverage/new_lcov.info -o coverage/html +open coverage/html/index.html \ No newline at end of file