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
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/Runner/GoogleService-Info.plist
lib/firebase_options.dart
coverage/

View file

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

View file

@ -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<MeteringEvent, MeteringState> {
final MeteringCommunicationBloc _communicationBloc;
final MeteringInteractor _meteringInteractor;
final MeteringCommunicationBloc _communicationBloc;
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(
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<EquipmentProfileChangedEvent>(_onEquipmentProfileChanged);
on<StopTypeChangedEvent>(_onStopTypeChanged);
on<FilmChangedEvent>(_onFilmChanged);
on<IsoChangedEvent>(_onIsoChanged);
on<NdChangedEvent>(_onNdChanged);
on<MeasureEvent>(_onMeasure);
on<MeasureEvent>(_onMeasure, transformer: droppable());
on<MeasuredEvent>(_onMeasured);
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
Future<void> 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<MeteringEvent, MeteringState> {
/// 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<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 {
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);
}

View file

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

View file

@ -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<MeteringMeasureButton> {
@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,
),
],
),
),
],
),
),
);

View file

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

View file

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

View file

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

View file

@ -22,12 +22,32 @@ class ZoomChangedEvent extends CameraContainerEvent {
final double 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 {
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 {

View file

@ -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<MeteringInteractor>(),
context.read<MeteringCommunicationBloc>(),
),
)..add(const RequestPermissionEvent()),
child: CameraContainer(
fastest: fastest,
slowest: slowest,

View file

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

View file

@ -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<int>? _luxSubscriptions;
double _ev100 = 0.0;
LightSensorContainerBloc(
this._meteringInteractor,
MeteringCommunicationBloc communicationBloc,
) : super(
communicationBloc,
const LightSensorInitState(),
);
const LightSensorContainerState(null),
) {
on<LuxMeteringEvent>(_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<void> close() async {
communicationBloc.add(communication_event.MeteringEndedEvent(_ev100));
_luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null);
_cancelMetering();
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 {
const LightSensorContainerEvent();
}
class LuxMeteringEvent extends LightSensorContainerEvent {
final int lux;
const LuxMeteringEvent(this.lux);
}

View file

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

View file

@ -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<E, S> extends Bloc<E, S> {
return super.close();
}
@visibleForTesting
void onCommunicationState(communication_states.SourceState communicationState);
}

View file

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

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/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<MeteringFlow> {
BlocProvider(create: (_) => MeteringCommunicationBloc()),
BlocProvider(
create: (context) => MeteringBloc(
context.read<MeteringCommunicationBloc>(),
context.get<MeteringInteractor>(),
context.get<EquipmentProfile>(),
context.get<StopType>(),
context.read<MeteringCommunicationBloc>(),
),
),
],

View file

@ -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<MeteringBloc, MeteringState>(
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<MeteringBloc, MeteringState>(
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<Environment>().hasLightSensor
? EvSourceTypeProvider.of(context).toggleType
: null,
@ -73,47 +71,41 @@ class _InheritedListeners extends StatelessWidget {
onDidChangeDependencies: (value) {
context.read<MeteringBloc>().add(EquipmentProfileChangedEvent(value));
},
child: InheritedWidgetListener<StopType>(
child: InheritedModelAspectListener<MeteringScreenLayoutFeature, bool>(
aspect: MeteringScreenLayoutFeature.filmPicker,
onDidChangeDependencies: (value) {
context.read<MeteringBloc>().add(StopTypeChangedEvent(value));
if (!value) context.read<MeteringBloc>().add(const FilmChangedEvent(Film.other()));
},
child: InheritedModelAspectListener<MeteringScreenLayoutFeature, bool>(
aspect: MeteringScreenLayoutFeature.filmPicker,
onDidChangeDependencies: (value) {
if (!value) context.read<MeteringBloc>().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<Film> onFilmChanged;
final ValueChanged<IsoValue> onIsoChanged;
final ValueChanged<NdValue> onNdChanged;
final List<ExposurePair> 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) : <ExposurePair>[];
final fastest = exposurePairs.isNotEmpty ? exposurePairs.first : null;
final slowest = exposurePairs.isNotEmpty ? exposurePairs.last : null;
return context.listen<EvSourceType>() == EvSourceType.camera
? CameraContainerProvider(
fastest: fastest,
@ -138,4 +130,68 @@ class _MeteringContainerBuidler extends StatelessWidget {
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: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<ExposurePair> 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;
}

View file

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

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