ML-62 Bloc's tests (#78)

* removed redundant `UserPreferencesService` from `MeteringBloc`

* wip

* post-merge fixes

* `MeasureEvent` tests

* `MeasureEvent` tests revision

* `MeasureEvent` tests added timeout

* added stubs for other `MeteringBloc` events

* rewritten `MeteringBloc` logic

* wip

* `IsoChangedEvent` tests

* refined `IsoChangedEvent` tests

* `NdChangedEvent` tests

* `FilmChangedEvent` tests

* `MeteringCommunicationBloc` tests

* added test run to ci

* overriden `==` for `MeasuredState`

* `LuxMeteringEvent` tests

* refined `LuxMeteringEvent` tests

* rename

* wip

* wip

* `InitializeEvent`/`DeinitializeEvent` tests

* clamp minZoomLevel

* fixed `MeteringCommunicationBloc` tests

* wip

* `ZoomChangedEvent` tests

* `ExposureOffsetChangedEvent`/`ExposureOffsetResetEvent` tests

* renamed test groups

* added test coverage script

* improved `CameraContainerBloc` test coverage

* `EquipmentProfileChangedEvent` tests

* verify response vibration

* fixed running all tests

* `MeteringCommunicationBloc` equality tests

* `CameraContainerBloc` equality tests

* removed generated code from coverage
This commit is contained in:
Vadim 2023-06-20 08:43:49 +02:00 committed by GitHub
parent 0013125d68
commit 74d0a7101c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1872 additions and 280 deletions

View file

@ -51,3 +51,6 @@ jobs:
- name: Analyze project source - name: Analyze project source
run: flutter analyze lib --fatal-infos run: flutter analyze lib --fatal-infos
- name: Run tests
run: flutter test

2
.gitignore vendored
View file

@ -58,3 +58,5 @@ android/app/google-services.json
ios/firebase_app_id_file.json ios/firebase_app_id_file.json
ios/Runner/GoogleService-Info.plist ios/Runner/GoogleService-Info.plist
lib/firebase_options.dart lib/firebase_options.dart
coverage/

View file

@ -31,6 +31,7 @@ class MeteringInteractor {
double get cameraEvCalibration => _userPreferencesService.cameraEvCalibration; double get cameraEvCalibration => _userPreferencesService.cameraEvCalibration;
double get lightSensorEvCalibration => _userPreferencesService.lightSensorEvCalibration; double get lightSensorEvCalibration => _userPreferencesService.lightSensorEvCalibration;
bool get isHapticsEnabled => _userPreferencesService.haptics;
IsoValue get iso => _userPreferencesService.iso; IsoValue get iso => _userPreferencesService.iso;
set iso(IsoValue value) => _userPreferencesService.iso = value; set iso(IsoValue value) => _userPreferencesService.iso = value;

View file

@ -1,8 +1,8 @@
import 'dart:async'; 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:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/data/models/exposure_pair.dart';
import 'package:lightmeter/data/models/film.dart'; import 'package:lightmeter/data/models/film.dart';
import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/interactors/metering_interactor.dart';
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.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'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class MeteringBloc extends Bloc<MeteringEvent, MeteringState> { class MeteringBloc extends Bloc<MeteringEvent, MeteringState> {
final MeteringCommunicationBloc _communicationBloc;
final MeteringInteractor _meteringInteractor; final MeteringInteractor _meteringInteractor;
final MeteringCommunicationBloc _communicationBloc;
late final StreamSubscription<communication_states.ScreenState> _communicationSubscription; late final StreamSubscription<communication_states.ScreenState> _communicationSubscription;
List<ApertureValue> get _apertureValues =>
_equipmentProfileData.apertureValues.whereStopType(stopType);
List<ShutterSpeedValue> 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( MeteringBloc(
this._communicationBloc,
this._meteringInteractor, this._meteringInteractor,
this._equipmentProfileData, this._communicationBloc,
this.stopType,
) : super( ) : super(
MeteringDataState( MeteringDataState(
ev: null, ev100: null,
film: _meteringInteractor.film, film: _meteringInteractor.film,
iso: _meteringInteractor.iso, iso: _meteringInteractor.iso,
nd: _meteringInteractor.ndFilter, nd: _meteringInteractor.ndFilter,
exposurePairs: const [], isMetering: false,
continuousMetering: false,
), ),
) { ) {
_communicationSubscription = _communicationBloc.stream _communicationSubscription = _communicationBloc.stream
.where((state) => state is communication_states.ScreenState) .where((state) => state is communication_states.ScreenState)
.map((state) => state as communication_states.ScreenState) .map((state) => state as communication_states.ScreenState)
.listen(_onCommunicationState); .listen(onCommunicationState);
on<EquipmentProfileChangedEvent>(_onEquipmentProfileChanged); on<EquipmentProfileChangedEvent>(_onEquipmentProfileChanged);
on<StopTypeChangedEvent>(_onStopTypeChanged);
on<FilmChangedEvent>(_onFilmChanged); on<FilmChangedEvent>(_onFilmChanged);
on<IsoChangedEvent>(_onIsoChanged); on<IsoChangedEvent>(_onIsoChanged);
on<NdChangedEvent>(_onNdChanged); on<NdChangedEvent>(_onNdChanged);
on<MeasureEvent>(_onMeasure); on<MeasureEvent>(_onMeasure, transformer: droppable());
on<MeasuredEvent>(_onMeasured); on<MeasuredEvent>(_onMeasured);
on<MeasureErrorEvent>(_onMeasureError); on<MeasureErrorEvent>(_onMeasureError);
} }
@override
void onTransition(Transition<MeteringEvent, MeteringState> 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 @override
Future<void> close() async { Future<void> close() async {
await _communicationSubscription.cancel(); await _communicationSubscription.cancel();
return super.close(); return super.close();
} }
void _onCommunicationState(communication_states.ScreenState communicationState) { @visibleForTesting
void onCommunicationState(communication_states.ScreenState communicationState) {
if (communicationState is communication_states.MeasuredState) { if (communicationState is communication_states.MeasuredState) {
_isMeteringInProgress = communicationState is communication_states.MeteringInProgressState; _handleEv100(
_handleEv100(communicationState.ev100); communicationState.ev100,
} isMetering: communicationState is communication_states.MeteringInProgressState,
} );
void _onStopTypeChanged(StopTypeChangedEvent event, Emitter emit) {
if (stopType != event.stopType) {
stopType = event.stopType;
_updateMeasurements();
} }
} }
void _onEquipmentProfileChanged(EquipmentProfileChangedEvent event, Emitter emit) { void _onEquipmentProfileChanged(EquipmentProfileChangedEvent event, Emitter emit) {
_equipmentProfileData = event.equipmentProfileData;
bool willUpdateMeasurements = false; 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 /// 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; _meteringInteractor.iso = event.equipmentProfileData.isoValues.first;
_iso = event.equipmentProfileData.isoValues.first; iso = event.equipmentProfileData.isoValues.first;
willUpdateMeasurements &= true; _meteringInteractor.film = Film.values.first;
film = Film.values.first;
willUpdateMeasurements = true;
} }
/// The same for ND filter /// 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; _meteringInteractor.ndFilter = event.equipmentProfileData.ndValues.first;
_nd = event.equipmentProfileData.ndValues.first; nd = event.equipmentProfileData.ndValues.first;
willUpdateMeasurements &= true; willUpdateMeasurements = true;
} }
if (willUpdateMeasurements) { if (willUpdateMeasurements) {
_updateMeasurements(); emit(
MeteringDataState(
ev100: state.ev100,
film: film,
iso: iso,
nd: nd,
isMetering: state.isMetering,
),
);
} }
} }
void _onFilmChanged(FilmChangedEvent event, Emitter emit) { void _onFilmChanged(FilmChangedEvent event, Emitter emit) {
if (_film.name != event.data.name) { if (state.film.name != event.film.name) {
_meteringInteractor.film = event.data; _meteringInteractor.film = event.film;
_film = event.data;
/// 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 /// If user selects 'Other' film we preserve currently selected ISO
/// and therefore only discard reciprocity formula /// and therefore only discard reciprocity formula
if (_iso.value != event.data.iso && event.data != const Film.other()) { emit(
final newIso = IsoValue.values.firstWhere( MeteringDataState(
(e) => e.value == event.data.iso, ev100: state.ev100,
orElse: () => _iso, film: event.film,
iso: iso,
nd: state.nd,
isMetering: state.isMetering,
),
); );
_meteringInteractor.iso = newIso;
_iso = newIso;
}
_updateMeasurements();
} }
} }
@ -132,131 +147,77 @@ class MeteringBloc extends Bloc<MeteringEvent, MeteringState> {
/// because, for example, Fomapan 400 and any Ilford 400 /// because, for example, Fomapan 400 and any Ilford 400
/// have different reciprocity formulas /// have different reciprocity formulas
_meteringInteractor.film = Film.values.first; _meteringInteractor.film = Film.values.first;
_film = Film.values.first;
if (_iso != event.isoValue) { if (state.iso != event.isoValue) {
_meteringInteractor.iso = event.isoValue; _meteringInteractor.iso = event.isoValue;
_iso = event.isoValue; emit(
_updateMeasurements(); MeteringDataState(
ev100: state.ev100,
film: Film.values.first,
iso: event.isoValue,
nd: state.nd,
isMetering: state.isMetering,
),
);
} }
} }
void _onNdChanged(NdChangedEvent event, Emitter emit) { void _onNdChanged(NdChangedEvent event, Emitter emit) {
if (_nd != event.ndValue) { if (state.nd != event.ndValue) {
_meteringInteractor.ndFilter = event.ndValue; _meteringInteractor.ndFilter = event.ndValue;
_nd = event.ndValue; emit(
_updateMeasurements(); MeteringDataState(
ev100: state.ev100,
film: state.film,
iso: state.iso,
nd: event.ndValue,
isMetering: state.isMetering,
),
);
} }
} }
void _onMeasure(MeasureEvent _, Emitter emit) { void _onMeasure(MeasureEvent _, Emitter emit) {
_meteringInteractor.quickVibration(); _meteringInteractor.quickVibration();
_communicationBloc.add(const communication_events.MeasureEvent()); _communicationBloc.add(const communication_events.MeasureEvent());
_isMeteringInProgress = true;
emit( emit(
LoadingState( LoadingState(
film: _film, film: state.film,
iso: _iso, iso: state.iso,
nd: _nd, nd: state.nd,
), ),
); );
} }
void _updateMeasurements() => _handleEv100(_ev100); void _handleEv100(double? ev100, {required bool isMetering}) {
void _handleEv100(double? ev100) {
if (ev100 == null || ev100.isNaN || ev100.isInfinite) { if (ev100 == null || ev100.isNaN || ev100.isInfinite) {
add(const MeasureErrorEvent()); add(MeasureErrorEvent(isMetering: isMetering));
} else { } else {
add(MeasuredEvent(ev100)); add(MeasuredEvent(ev100, isMetering: isMetering));
} }
} }
void _onMeasured(MeasuredEvent event, Emitter emit) { void _onMeasured(MeasuredEvent event, Emitter emit) {
_meteringInteractor.responseVibration();
_ev100 = event.ev100;
final ev = event.ev100 + log2(_iso.value / 100) - _nd.stopReduction;
emit( emit(
MeteringDataState( MeteringDataState(
ev: ev, ev100: event.ev100,
film: _film, film: state.film,
iso: _iso, iso: state.iso,
nd: _nd, nd: state.nd,
exposurePairs: _buildExposureValues(ev), isMetering: event.isMetering,
continuousMetering: _isMeteringInProgress,
), ),
); );
} }
void _onMeasureError(MeasureErrorEvent _, Emitter emit) { void _onMeasureError(MeasureErrorEvent event, Emitter emit) {
_meteringInteractor.errorVibration();
_ev100 = null;
emit( emit(
MeteringDataState( MeteringDataState(
ev: null, ev100: null,
film: _film, film: state.film,
iso: _iso, iso: state.iso,
nd: _nd, nd: state.nd,
exposurePairs: const [], isMetering: event.isMetering,
continuousMetering: _isMeteringInProgress,
), ),
); );
} }
List<ExposurePair> _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,
);
}
} }

View file

@ -22,8 +22,28 @@ abstract class MeasuredEvent extends SourceEvent {
class MeteringInProgressEvent extends MeasuredEvent { class MeteringInProgressEvent extends MeasuredEvent {
const MeteringInProgressEvent(super.ev100); 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 { class MeteringEndedEvent extends MeasuredEvent {
const MeteringEndedEvent(super.ev100); 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);
} }

View file

@ -1,4 +1,4 @@
abstract class MeteringCommunicationState { sealed class MeteringCommunicationState {
const MeteringCommunicationState(); const MeteringCommunicationState();
} }
@ -6,11 +6,11 @@ class InitState extends MeteringCommunicationState {
const InitState(); const InitState();
} }
abstract class SourceState extends MeteringCommunicationState { sealed class SourceState extends MeteringCommunicationState {
const SourceState(); const SourceState();
} }
abstract class ScreenState extends MeteringCommunicationState { sealed class ScreenState extends MeteringCommunicationState {
const ScreenState(); const ScreenState();
} }
@ -18,7 +18,7 @@ class MeasureState extends SourceState {
const MeasureState(); const MeasureState();
} }
abstract class MeasuredState extends ScreenState { sealed class MeasuredState extends ScreenState {
final double? ev100; final double? ev100;
const MeasuredState(this.ev100); const MeasuredState(this.ev100);
@ -26,8 +26,28 @@ abstract class MeasuredState extends ScreenState {
class MeteringInProgressState extends MeasuredState { class MeteringInProgressState extends MeasuredState {
const MeteringInProgressState(super.ev100); 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 { class MeteringEndedState extends MeasuredState {
const MeteringEndedState(super.ev100); 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);
} }

View file

@ -6,13 +6,11 @@ import 'package:lightmeter/screens/shared/filled_circle/widget_circle_filled.dar
class MeteringMeasureButton extends StatefulWidget { class MeteringMeasureButton extends StatefulWidget {
final double? ev; final double? ev;
final bool isMetering; final bool isMetering;
final bool hasError;
final VoidCallback onTap; final VoidCallback onTap;
const MeteringMeasureButton({ const MeteringMeasureButton({
required this.ev, required this.ev,
required this.isMetering, required this.isMetering,
required this.hasError,
required this.onTap, required this.onTap,
super.key, super.key,
}); });
@ -34,9 +32,7 @@ class _MeteringMeasureButtonState extends State<MeteringMeasureButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IgnorePointer( return GestureDetector(
ignoring: widget.isMetering && widget.ev == null && !widget.hasError,
child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
onTapDown: (_) { onTapDown: (_) {
setState(() { setState(() {
@ -65,13 +61,7 @@ class _MeteringMeasureButtonState extends State<MeteringMeasureButton> {
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
size: Dimens.grid72 - Dimens.grid8, size: Dimens.grid72 - Dimens.grid8,
child: Center( child: Center(
child: widget.hasError child: widget.ev != null ? _EvValueText(ev: widget.ev!) : null,
? Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.surface,
size: Dimens.grid24,
)
: (widget.ev != null ? _EvValueText(ev: widget.ev!) : null),
), ),
), ),
), ),
@ -87,7 +77,6 @@ class _MeteringMeasureButtonState extends State<MeteringMeasureButton> {
], ],
), ),
), ),
),
); );
} }
} }

View file

@ -6,7 +6,6 @@ import 'package:lightmeter/screens/metering/components/bottom_controls/widget_bo
class MeteringBottomControlsProvider extends StatelessWidget { class MeteringBottomControlsProvider extends StatelessWidget {
final double? ev; final double? ev;
final bool isMetering; final bool isMetering;
final bool hasError;
final VoidCallback? onSwitchEvSourceType; final VoidCallback? onSwitchEvSourceType;
final VoidCallback onMeasure; final VoidCallback onMeasure;
final VoidCallback onSettings; final VoidCallback onSettings;
@ -14,7 +13,6 @@ class MeteringBottomControlsProvider extends StatelessWidget {
const MeteringBottomControlsProvider({ const MeteringBottomControlsProvider({
required this.ev, required this.ev,
required this.isMetering, required this.isMetering,
required this.hasError,
required this.onSwitchEvSourceType, required this.onSwitchEvSourceType,
required this.onMeasure, required this.onMeasure,
required this.onSettings, required this.onSettings,
@ -38,7 +36,6 @@ class MeteringBottomControlsProvider extends StatelessWidget {
child: MeteringBottomControls( child: MeteringBottomControls(
ev: ev, ev: ev,
isMetering: isMetering, isMetering: isMetering,
hasError: hasError,
onSwitchEvSourceType: onSwitchEvSourceType, onSwitchEvSourceType: onSwitchEvSourceType,
onMeasure: onMeasure, onMeasure: onMeasure,
onSettings: onSettings, onSettings: onSettings,

View file

@ -7,7 +7,6 @@ import 'package:lightmeter/utils/inherited_generics.dart';
class MeteringBottomControls extends StatelessWidget { class MeteringBottomControls extends StatelessWidget {
final double? ev; final double? ev;
final bool isMetering; final bool isMetering;
final bool hasError;
final VoidCallback? onSwitchEvSourceType; final VoidCallback? onSwitchEvSourceType;
final VoidCallback onMeasure; final VoidCallback onMeasure;
final VoidCallback onSettings; final VoidCallback onSettings;
@ -15,7 +14,6 @@ class MeteringBottomControls extends StatelessWidget {
const MeteringBottomControls({ const MeteringBottomControls({
required this.ev, required this.ev,
required this.isMetering, required this.isMetering,
required this.hasError,
required this.onSwitchEvSourceType, required this.onSwitchEvSourceType,
required this.onMeasure, required this.onMeasure,
required this.onSettings, required this.onSettings,
@ -56,7 +54,6 @@ class MeteringBottomControls extends StatelessWidget {
MeteringMeasureButton( MeteringMeasureButton(
ev: ev, ev: ev,
isMetering: isMetering, isMetering: isMetering,
hasError: hasError,
onTap: onMeasure, onTap: onMeasure,
), ),
Expanded( Expanded(

View file

@ -53,8 +53,6 @@ class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraC
on<ZoomChangedEvent>(_onZoomChanged); on<ZoomChangedEvent>(_onZoomChanged);
on<ExposureOffsetChangedEvent>(_onExposureOffsetChanged); on<ExposureOffsetChangedEvent>(_onExposureOffsetChanged);
on<ExposureOffsetResetEvent>(_onExposureOffsetResetEvent); on<ExposureOffsetResetEvent>(_onExposureOffsetResetEvent);
add(const RequestPermissionEvent());
} }
@override @override
@ -124,7 +122,7 @@ class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraC
_zoomRange = await Future.wait<double>([ _zoomRange = await Future.wait<double>([
_cameraController!.getMinZoomLevel(), _cameraController!.getMinZoomLevel(),
_cameraController!.getMaxZoomLevel(), _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; _currentZoom = _zoomRange!.start;
_exposureOffsetRange = await Future.wait<double>([ _exposureOffsetRange = await Future.wait<double>([
@ -210,8 +208,8 @@ class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraC
final speed = speedValueRatio.numerator / speedValueRatio.denominator; final speed = speedValueRatio.numerator / speedValueRatio.denominator;
return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100); return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100);
} on CameraException catch (e) { } catch (e) {
log('Error: ${e.code}\nError Message: ${e.description}'); log(e.toString());
return null; return null;
} }
} }

View file

@ -22,12 +22,32 @@ class ZoomChangedEvent extends CameraContainerEvent {
final double value; final double value;
const ZoomChangedEvent(this.value); const ZoomChangedEvent(this.value);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other.runtimeType != runtimeType) return false;
return other is ZoomChangedEvent && other.value == value;
}
@override
int get hashCode => Object.hash(value, runtimeType);
} }
class ExposureOffsetChangedEvent extends CameraContainerEvent { class ExposureOffsetChangedEvent extends CameraContainerEvent {
final double value; final double value;
const ExposureOffsetChangedEvent(this.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 { class ExposureOffsetResetEvent extends CameraContainerEvent {

View file

@ -5,6 +5,7 @@ import 'package:lightmeter/data/models/film.dart';
import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/interactors/metering_interactor.dart';
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.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/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/screens/metering/components/camera_container/widget_container_camera.dart';
import 'package:lightmeter/utils/inherited_generics.dart'; import 'package:lightmeter/utils/inherited_generics.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
@ -40,7 +41,7 @@ class CameraContainerProvider extends StatelessWidget {
create: (context) => CameraContainerBloc( create: (context) => CameraContainerBloc(
context.get<MeteringInteractor>(), context.get<MeteringInteractor>(),
context.read<MeteringCommunicationBloc>(), context.read<MeteringCommunicationBloc>(),
), )..add(const RequestPermissionEvent()),
child: CameraContainer( child: CameraContainer(
fastest: fastest, fastest: fastest,
slowest: slowest, slowest: slowest,

View file

@ -34,10 +34,42 @@ class CameraActiveState extends CameraContainerState {
required this.exposureOffsetStep, required this.exposureOffsetStep,
required this.currentExposureOffset, 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 { class CameraErrorState extends CameraContainerState {
final CameraErrorType error; final CameraErrorType error;
const CameraErrorState(this.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);
} }

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/interactors/metering_interactor.dart';
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.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/event_communication_metering.dart'
@ -16,35 +17,46 @@ class LightSensorContainerBloc
final MeteringInteractor _meteringInteractor; final MeteringInteractor _meteringInteractor;
StreamSubscription<int>? _luxSubscriptions; StreamSubscription<int>? _luxSubscriptions;
double _ev100 = 0.0;
LightSensorContainerBloc( LightSensorContainerBloc(
this._meteringInteractor, this._meteringInteractor,
MeteringCommunicationBloc communicationBloc, MeteringCommunicationBloc communicationBloc,
) : super( ) : super(
communicationBloc, communicationBloc,
const LightSensorInitState(), const LightSensorContainerState(null),
); ) {
on<LuxMeteringEvent>(_onLuxMeteringEvent);
}
@override @override
void onCommunicationState(communication_states.SourceState communicationState) { void onCommunicationState(communication_states.SourceState communicationState) {
if (communicationState is communication_states.MeasureState) { if (communicationState is communication_states.MeasureState) {
if (_luxSubscriptions == null) { if (_luxSubscriptions == null) {
_luxSubscriptions = _meteringInteractor.luxStream().listen((event) { _startMetering();
_ev100 = log2(event.toDouble() / 2.5) + _meteringInteractor.lightSensorEvCalibration;
communicationBloc.add(communication_event.MeteringInProgressEvent(_ev100));
});
} else { } else {
communicationBloc.add(communication_event.MeteringEndedEvent(_ev100)); _cancelMetering();
_luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null);
} }
} }
} }
@override @override
Future<void> close() async { Future<void> close() async {
communicationBloc.add(communication_event.MeteringEndedEvent(_ev100)); _cancelMetering();
_luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null);
return super.close(); return super.close();
} }
void _onLuxMeteringEvent(LuxMeteringEvent event, Emitter<LightSensorContainerState> 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);
}
} }

View file

@ -1,3 +1,9 @@
abstract class LightSensorContainerEvent { abstract class LightSensorContainerEvent {
const LightSensorContainerEvent(); const LightSensorContainerEvent();
} }
class LuxMeteringEvent extends LightSensorContainerEvent {
final int lux;
const LuxMeteringEvent(this.lux);
}

View file

@ -1,7 +1,5 @@
abstract class LightSensorContainerState { class LightSensorContainerState {
const LightSensorContainerState(); final double? ev100;
}
class LightSensorInitState extends LightSensorContainerState { const LightSensorContainerState(this.ev100);
const LightSensorInitState();
} }

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart';
import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart'
@ -21,5 +22,6 @@ abstract class EvSourceBlocBase<E, S> extends Bloc<E, S> {
return super.close(); return super.close();
} }
@visibleForTesting
void onCommunicationState(communication_states.SourceState communicationState); void onCommunicationState(communication_states.SourceState communicationState);
} }

View file

@ -1,16 +1,10 @@
import 'package:lightmeter/data/models/film.dart'; import 'package:lightmeter/data/models/film.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
abstract class MeteringEvent { sealed class MeteringEvent {
const MeteringEvent(); const MeteringEvent();
} }
class StopTypeChangedEvent extends MeteringEvent {
final StopType stopType;
const StopTypeChangedEvent(this.stopType);
}
class EquipmentProfileChangedEvent extends MeteringEvent { class EquipmentProfileChangedEvent extends MeteringEvent {
final EquipmentProfileData equipmentProfileData; final EquipmentProfileData equipmentProfileData;
@ -18,9 +12,9 @@ class EquipmentProfileChangedEvent extends MeteringEvent {
} }
class FilmChangedEvent extends MeteringEvent { class FilmChangedEvent extends MeteringEvent {
final Film data; final Film film;
const FilmChangedEvent(this.data); const FilmChangedEvent(this.film);
} }
class IsoChangedEvent extends MeteringEvent { class IsoChangedEvent extends MeteringEvent {
@ -41,10 +35,13 @@ class MeasureEvent extends MeteringEvent {
class MeasuredEvent extends MeteringEvent { class MeasuredEvent extends MeteringEvent {
final double ev100; final double ev100;
final bool isMetering;
const MeasuredEvent(this.ev100); const MeasuredEvent(this.ev100, {required this.isMetering});
} }
class MeasureErrorEvent extends MeteringEvent { class MeasureErrorEvent extends MeteringEvent {
const MeasureErrorEvent(); final bool isMetering;
const MeasureErrorEvent({required this.isMetering});
} }

View file

@ -6,12 +6,10 @@ import 'package:lightmeter/data/light_sensor_service.dart';
import 'package:lightmeter/data/permissions_service.dart'; import 'package:lightmeter/data/permissions_service.dart';
import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/interactors/metering_interactor.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/bloc_metering.dart';
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart';
import 'package:lightmeter/screens/metering/screen_metering.dart'; import 'package:lightmeter/screens/metering/screen_metering.dart';
import 'package:lightmeter/utils/inherited_generics.dart'; import 'package:lightmeter/utils/inherited_generics.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class MeteringFlow extends StatefulWidget { class MeteringFlow extends StatefulWidget {
const MeteringFlow({super.key}); const MeteringFlow({super.key});
@ -36,10 +34,8 @@ class _MeteringFlowState extends State<MeteringFlow> {
BlocProvider(create: (_) => MeteringCommunicationBloc()), BlocProvider(create: (_) => MeteringCommunicationBloc()),
BlocProvider( BlocProvider(
create: (context) => MeteringBloc( create: (context) => MeteringBloc(
context.read<MeteringCommunicationBloc>(),
context.get<MeteringInteractor>(), context.get<MeteringInteractor>(),
context.get<EquipmentProfile>(), context.read<MeteringCommunicationBloc>(),
context.get<StopType>(),
), ),
), ),
], ],

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/ev_source_type.dart';
@ -29,9 +31,7 @@ class MeteringScreen extends StatelessWidget {
Expanded( Expanded(
child: BlocBuilder<MeteringBloc, MeteringState>( child: BlocBuilder<MeteringBloc, MeteringState>(
builder: (_, state) => _MeteringContainerBuidler( builder: (_, state) => _MeteringContainerBuidler(
fastest: state is MeteringDataState ? state.fastest : null, ev: state is MeteringDataState ? state.ev : null,
slowest: state is MeteringDataState ? state.slowest : null,
exposurePairs: state is MeteringDataState ? state.exposurePairs : [],
film: state.film, film: state.film,
iso: state.iso, iso: state.iso,
nd: state.nd, nd: state.nd,
@ -45,9 +45,7 @@ class MeteringScreen extends StatelessWidget {
BlocBuilder<MeteringBloc, MeteringState>( BlocBuilder<MeteringBloc, MeteringState>(
builder: (context, state) => MeteringBottomControlsProvider( builder: (context, state) => MeteringBottomControlsProvider(
ev: state is MeteringDataState ? state.ev : null, ev: state is MeteringDataState ? state.ev : null,
isMetering: isMetering: state.isMetering,
state is LoadingState || state is MeteringDataState && state.continuousMetering,
hasError: state is MeteringDataState && state.hasError,
onSwitchEvSourceType: context.get<Environment>().hasLightSensor onSwitchEvSourceType: context.get<Environment>().hasLightSensor
? EvSourceTypeProvider.of(context).toggleType ? EvSourceTypeProvider.of(context).toggleType
: null, : null,
@ -73,10 +71,6 @@ class _InheritedListeners extends StatelessWidget {
onDidChangeDependencies: (value) { onDidChangeDependencies: (value) {
context.read<MeteringBloc>().add(EquipmentProfileChangedEvent(value)); context.read<MeteringBloc>().add(EquipmentProfileChangedEvent(value));
}, },
child: InheritedWidgetListener<StopType>(
onDidChangeDependencies: (value) {
context.read<MeteringBloc>().add(StopTypeChangedEvent(value));
},
child: InheritedModelAspectListener<MeteringScreenLayoutFeature, bool>( child: InheritedModelAspectListener<MeteringScreenLayoutFeature, bool>(
aspect: MeteringScreenLayoutFeature.filmPicker, aspect: MeteringScreenLayoutFeature.filmPicker,
onDidChangeDependencies: (value) { onDidChangeDependencies: (value) {
@ -84,36 +78,34 @@ class _InheritedListeners extends StatelessWidget {
}, },
child: child, child: child,
), ),
),
); );
} }
} }
class _MeteringContainerBuidler extends StatelessWidget { class _MeteringContainerBuidler extends StatelessWidget {
final ExposurePair? fastest; final double? ev;
final ExposurePair? slowest;
final Film film; final Film film;
final IsoValue iso; final IsoValue iso;
final NdValue nd; final NdValue nd;
final ValueChanged<Film> onFilmChanged; final ValueChanged<Film> onFilmChanged;
final ValueChanged<IsoValue> onIsoChanged; final ValueChanged<IsoValue> onIsoChanged;
final ValueChanged<NdValue> onNdChanged; final ValueChanged<NdValue> onNdChanged;
final List<ExposurePair> exposurePairs;
const _MeteringContainerBuidler({ const _MeteringContainerBuidler({
required this.fastest, required this.ev,
required this.slowest,
required this.film, required this.film,
required this.iso, required this.iso,
required this.nd, required this.nd,
required this.onFilmChanged, required this.onFilmChanged,
required this.onIsoChanged, required this.onIsoChanged,
required this.onNdChanged, required this.onNdChanged,
required this.exposurePairs,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final exposurePairs = ev != null ? buildExposureValues(context, ev!, film) : <ExposurePair>[];
final fastest = exposurePairs.isNotEmpty ? exposurePairs.first : null;
final slowest = exposurePairs.isNotEmpty ? exposurePairs.last : null;
return context.listen<EvSourceType>() == EvSourceType.camera return context.listen<EvSourceType>() == EvSourceType.camera
? CameraContainerProvider( ? CameraContainerProvider(
fastest: fastest, fastest: fastest,
@ -138,4 +130,68 @@ class _MeteringContainerBuidler extends StatelessWidget {
exposurePairs: exposurePairs, exposurePairs: exposurePairs,
); );
} }
List<ExposurePair> 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<StopType>();
final int evSteps = (ev * (stopType.index + 1)).round();
final EquipmentProfile equipmentProfile = context.listen<EquipmentProfile>();
final List<ApertureValue> apertureValues =
equipmentProfile.apertureValues.whereStopType(stopType);
final List<ShutterSpeedValue> 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,
);
}
} }

View file

@ -1,18 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/exposure_pair.dart';
import 'package:lightmeter/data/models/film.dart'; import 'package:lightmeter/data/models/film.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
@immutable @immutable
abstract class MeteringState { abstract class MeteringState {
final double? ev100;
final Film film; final Film film;
final IsoValue iso; final IsoValue iso;
final NdValue nd; final NdValue nd;
final bool isMetering;
const MeteringState({ const MeteringState({
this.ev100,
required this.film, required this.film,
required this.iso, required this.iso,
required this.nd, required this.nd,
required this.isMetering,
}); });
} }
@ -21,24 +24,18 @@ class LoadingState extends MeteringState {
required super.film, required super.film,
required super.iso, required super.iso,
required super.nd, required super.nd,
}); }) : super(isMetering: true);
} }
class MeteringDataState extends MeteringState { class MeteringDataState extends MeteringState {
final double? ev;
final List<ExposurePair> exposurePairs;
final bool continuousMetering;
const MeteringDataState({ const MeteringDataState({
required this.ev, required super.ev100,
required super.film, required super.film,
required super.iso, required super.iso,
required super.nd, required super.nd,
required this.exposurePairs, required super.isMetering,
required this.continuousMetering,
}); });
ExposurePair? get fastest => exposurePairs.isEmpty ? null : exposurePairs.first; double? get ev => ev100 != null ? ev100! + log2(iso.value / 100) - nd.stopReduction : null;
ExposurePair? get slowest => exposurePairs.isEmpty ? null : exposurePairs.last;
bool get hasError => ev == null; bool get hasError => ev == null;
} }

View file

@ -8,6 +8,7 @@ environment:
dependencies: dependencies:
app_settings: 4.2.0 app_settings: 4.2.0
bloc_concurrency: 0.2.2
camera: 0.10.5 camera: 0.10.5
clipboard: 0.1.3 clipboard: 0.1.3
dynamic_color: 1.6.5 dynamic_color: 1.6.5
@ -16,7 +17,7 @@ dependencies:
firebase_crashlytics: 3.3.1 firebase_crashlytics: 3.3.1
flutter: flutter:
sdk: flutter sdk: flutter
flutter_bloc: 8.1.2 flutter_bloc: 8.1.3
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
intl: 0.18.0 intl: 0.18.0
@ -35,10 +36,15 @@ dependencies:
vibration: 1.7.7 vibration: 1.7.7
dev_dependencies: dev_dependencies:
bloc_test: 9.1.3
build_runner: ^2.1.7
flutter_launcher_icons: 0.11.0 flutter_launcher_icons: 0.11.0
flutter_native_splash: 2.2.16 flutter_native_splash: 2.2.16
flutter_test:
sdk: flutter
google_fonts: 3.0.1 google_fonts: 3.0.1
lint: 2.1.2 lint: 2.1.2
mocktail: 0.3.0
test: 1.24.1 test: 1.24.1
flutter: flutter:

View file

@ -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<IsoValue>(() => meteringInteractor.iso).thenReturn(iso100);
when<NdValue>(() => meteringInteractor.ndFilter).thenReturn(NdValue.values.first);
when<Film>(() => 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<MeteringBloc, MeteringState>(
'`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<LoadingState>(),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', false)
.having((state) => state.ev, 'ev', 2),
],
);
blocTest<MeteringBloc, MeteringState>(
'`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<LoadingState>(),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', false)
.having((state) => state.ev100, 'ev100', null)
.having((state) => state.ev, 'ev', null),
],
);
blocTest<MeteringBloc, MeteringState>(
'`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<LoadingState>(),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', true)
.having((state) => state.ev, 'ev', null),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', true)
.having((state) => state.ev, 'ev', 2),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', true)
.having((state) => state.ev, 'ev', 5.5),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', true)
.having((state) => state.ev, 'ev', null),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', true)
.having((state) => state.ev, 'ev', 4),
isA<LoadingState>(),
isA<MeteringDataState>()
.having((state) => state.isMetering, 'isMetering', false)
.having((state) => state.ev, 'ev', 4),
],
);
},
timeout: const Timeout(Duration(seconds: 4)),
);
group(
'`IsoChangedEvent`',
() {
blocTest<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<LoadingState>(),
isA<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<LoadingState>(),
isA<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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<MeteringBloc, MeteringState>(
'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<MeteringDataState>()
.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),
],
);
},
);
}

View file

@ -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<MeteringCommunicationBloc, MeteringCommunicationState>(
'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<MeasureState>(),
isA<MeasureState>(),
isA<MeasureState>(),
isA<MeasureState>(),
],
);
blocTest<MeteringCommunicationBloc, MeteringCommunicationState>(
'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<MeasureState>(),
isA<MeteringInProgressState>().having((state) => state.ev100, 'ev100', 1),
isA<MeteringInProgressState>().having((state) => state.ev100, 'ev100', null),
isA<MeteringInProgressState>().having((state) => state.ev100, 'ev100', 2),
isA<MeasureState>(),
isA<MeteringEndedState>().having((state) => state.ev100, 'ev100', 2),
],
);
},
);
group(
'`MeteringInProgressEvent`',
() {
blocTest<MeteringCommunicationBloc, MeteringCommunicationState>(
'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<MeteringInProgressState>().having((state) => state.ev100, 'ev100', 1),
isA<MeteringInProgressState>().having((state) => state.ev100, 'ev100', null),
isA<MeteringInProgressState>().having((state) => state.ev100, 'ev100', 2),
],
);
},
);
group(
'`MeteringEndedEvent`',
() {
blocTest<MeteringCommunicationBloc, MeteringCommunicationState>(
'Multiple consequtive ended events',
build: () => bloc,
act: (bloc) async {
bloc.add(const MeteringEndedEvent(1));
},
expect: () => [
isA<MeteringEndedState>().having((state) => state.ev100, 'ev100', 1),
],
);
},
);
}

View file

@ -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);
});
},
);
}

View file

@ -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);
});
},
);
}

View file

@ -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<Object?>? cameraMethodCallSuccessHandler(
MethodCall methodCall, {
List<Map<String, Object>> 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<CameraLoadingState>(),
isA<CameraInitializedState>(),
isA<CameraActiveState>()
.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<CameraContainerBloc, CameraContainerState>(
'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<CameraErrorState>()
.having((state) => state.error, "error", CameraErrorType.permissionNotGranted),
],
);
blocTest<CameraContainerBloc, CameraContainerState>(
'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<CameraLoadingState>(),
isA<CameraErrorState>()
.having((state) => state.error, "error", CameraErrorType.permissionNotGranted),
],
);
blocTest<CameraContainerBloc, CameraContainerState>(
'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<CameraContainerBloc, CameraContainerState>(
'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<CameraContainerBloc, CameraContainerState>(
'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<CameraLoadingState>(),
isA<CameraErrorState>()
.having((state) => state.error, "error", CameraErrorType.noCamerasDetected),
],
);
blocTest<CameraContainerBloc, CameraContainerState>(
'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<CameraContainerBloc, CameraContainerState>(
'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<CameraLoadingState>(),
isA<CameraErrorState>().having((state) => state.error, "error", CameraErrorType.other),
],
);
blocTest<CameraContainerBloc, CameraContainerState>(
'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<CameraContainerBloc, CameraContainerState>(
'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<CameraContainerBloc, CameraContainerState>(
// '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<CameraContainerBloc, CameraContainerState>(
'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<CameraActiveState>()
.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<CameraActiveState>()
.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<CameraContainerBloc, CameraContainerState>(
'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<CameraActiveState>()
.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<CameraActiveState>()
.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<CameraActiveState>()
.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<void> invokeMockMethod(String method, dynamic arguments) async {
final data = const StandardMethodCodec().encodeMethodCall(MethodCall(method, arguments));
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
name,
data,
(ByteData? data) {},
);
}
}

View file

@ -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);
});
},
);
}

View file

@ -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);
});
},
);
}

View file

@ -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<int> luxIterable = [1, 2, 2, 2, 3];
final List<double> resultList = luxIterable.map((lux) => log2(lux / 2.5)).toList();
blocTest<LightSensorContainerBloc, LightSensorContainerState>(
'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<LightSensorContainerState>().having((state) => state.ev100, 'ev100', e),
),
);
},
);
}

4
test_coverage.sh Normal file
View file

@ -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