ML-160 Integration tests (#161)

* test granting and revoking pro features

* extracted common widget tester actions

* test disabling & enabling of the metering screen layout features

* added integration tests to CI

* added integration tests to PR check

* allow matrix jobs to fail

* use base64 -d

* downgraded iphone version to the supported one

* use proper android device name

* typo in macos version

* upgraded iphone version to the supported one

* updated android compileSdkVersion

* added google services json restoration

* combined all tests in one file

* removed ipa signing for ios test

* debug prints :)

* lints

* refined tester extension and expectations

* e2e test (wip)

* added more expectations to e2e test

* changed pickers order a bit in e2e test

* added equipment profiles creation to e2e test

* added film selection to e2e test

* set android emulator API level to 32

* use flutter drive for integration tests

* removed app pre-build

* try running tests only for one platform

* added no-dds to flutter drive

* try running only on ios

* bumped macos version

* increased tests timeout

* set IPHONEOS_DEPLOYMENT_TARGET = 12.0

* removed prints

* Update Podfile

* restore firebase_app_id_file.json

* Delete run_integration_tests.sh

* run e2e with all tests

* reverted pr-check
This commit is contained in:
Vadim 2024-03-13 15:34:26 +01:00 committed by GitHub
parent 134af8ad28
commit 7787558713
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 853 additions and 22 deletions

View file

@ -11,4 +11,4 @@ if [[ ! -n "$filename" ]]; then
exit 1 exit 1
fi fi
echo -n "$content" | base64 --decode --output "$filename" base64 -d <<< "$content" > "$filename"

View file

@ -0,0 +1,56 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Run integration tests
on:
workflow_dispatch:
workflow_call:
jobs:
run-integration-tests:
name: Run integration tests
timeout-minutes: 60
runs-on: macos-13
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Override iap package with stub
id: override-iap
run: bash ./.github/scripts/stub_iap.sh
- name: Restore secrets
run: |
bash .github/scripts/restore_from_base64.sh "${{ secrets.CONSTANTS }}" "lib/constants.dart"
bash .github/scripts/restore_from_base64.sh "${{ secrets.GOOGLE_SERVICES_JSON_IOS }}" "ios/Runner/GoogleService-Info.plist"
bash .github/scripts/restore_from_base64.sh "${{ secrets.FIREBASE_APP_ID_FILE }}" "ios/firebase_app_id_file.json"
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.0"
- name: Prepare app
run: |
flutter --version
flutter pub get
flutter pub run intl_utils:generate
flutter analyze lib --fatal-infos
- name: Launch iOS simulator
uses: futureware-tech/simulator-action@v3
with:
model: "iPhone 15 Pro"
- name: Run tests
run: |
flutter drive \
--target=integration_test/run_all_tests.dart \
--driver=test_driver/integration_driver.dart \
--flavor=dev \
--no-dds \
--dart-define cameraStubImage=assets/camera_stub_image.jpg

View file

@ -34,7 +34,7 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
compileSdkVersion 33 compileSdkVersion 34
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {

View file

@ -0,0 +1,18 @@
# M3 Lightmeter integration tests
### List of executed tests:
- [Purchases test](integration_test/purchases_test.dart)
- [Metering screen layout test](integration_test/metering_screen_layout_test.dart)
- [e2e](integration_test/e2e_test.dart)
### Run all tests
```console
flutter drive \
--target=integration_test/run_all_tests.dart \
--driver=test_driver/integration_driver.dart \
--flavor=dev \
--no-dds \
--dart-define cameraStubImage=assets/camera_stub_image.jpg
```

View file

@ -0,0 +1,302 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart';
import 'package:lightmeter/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart';
import 'package:lightmeter/screens/settings/components/shared/dialog_range_picker/widget_dialog_picker_range.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../integration_test/utils/widget_tester_actions.dart';
import 'mocks/paid_features_mock.dart';
import 'utils/expectations.dart';
@isTest
void testE2E(String description) {
setUp(() {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
});
});
testWidgets(
description,
(tester) async {
await tester.pumpApplication(equipmentProfiles: [], films: []);
/// Create Praktica + Zenitar profile from scratch
await tester.openSettings();
await tester.tapDescendantTextOf<SettingsScreen>(S.current.equipmentProfiles);
await tester.tap(find.byIcon(Icons.add).first);
await tester.pumpAndSettle();
await tester.setProfileName(mockEquipmentProfiles[0].name);
await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[0].name);
await tester.setIsoValues(0, mockEquipmentProfiles[0].isoValues);
await tester.setNdValues(0, mockEquipmentProfiles[0].ndValues);
await tester.setApertureValues(0, mockEquipmentProfiles[0].apertureValues);
await tester.setShutterSpeedValues(0, mockEquipmentProfiles[0].shutterSpeedValues);
expect(find.text('f/1.7 - f/16'), findsOneWidget);
expect(find.text('1/1000 - 16"'), findsOneWidget);
/// Create Praktica + Jupiter profile from Zenitar profile
await tester.tap(find.byIcon(Icons.copy).first);
await tester.pumpAndSettle();
await tester.setProfileName(mockEquipmentProfiles[1].name);
await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[1].name);
await tester.setApertureValues(1, mockEquipmentProfiles[1].apertureValues);
expect(find.text('f/3.5 - f/22'), findsOneWidget);
expect(find.text('1/1000 - 16"'), findsNWidgets(2));
await tester.navigatorPop();
/// Select some films
await tester.tap(find.text(S.current.filmsInUse));
await tester.pumpAndSettle();
await tester.setDialogFilterValues<Film>([mockFilms[0], mockFilms[1]], deselectAll: false);
await tester.navigatorPop();
/// Select some initial settings according to the selected gear and film
/// Then take a photo and verify, that exposure pairs range and EV matches the selected settings
await tester.openPickerAndSelect<EquipmentProfilePicker, EquipmentProfile>(mockEquipmentProfiles[0].name);
await tester.openPickerAndSelect<FilmPicker, Film>(mockFilms[0].name);
await tester.openPickerAndSelect<IsoValuePicker, IsoValue>('400');
expectPickerTitle<EquipmentProfilePicker>(mockEquipmentProfiles[0].name);
expectPickerTitle<FilmPicker>(mockFilms[0].name);
expectPickerTitle<IsoValuePicker>('400');
await tester.takePhoto();
await _expectMeteringState(
tester,
equipmentProfile: mockEquipmentProfiles[0],
film: mockFilms[0],
fastest: 'f/1.8 - 1/400',
slowest: 'f/16 - 1/5',
iso: '400',
nd: 'None',
ev: mockPhotoEv100 + 2,
);
/// Add ND to shoot another scene
await tester.openPickerAndSelect<NdValuePicker, NdValue>('2');
await _expectMeteringStateAndMeasure(
tester,
equipmentProfile: mockEquipmentProfiles[0],
film: mockFilms[0],
fastest: 'f/1.8 - 1/200',
slowest: 'f/16 - 1/2.5',
iso: '400',
nd: '2',
ev: mockPhotoEv100 + 2 - 1,
);
/// Select another lens without ND
await tester.openPickerAndSelect<EquipmentProfilePicker, EquipmentProfile>(mockEquipmentProfiles[1].name);
await tester.openPickerAndSelect<NdValuePicker, NdValue>('None');
await _expectMeteringStateAndMeasure(
tester,
equipmentProfile: mockEquipmentProfiles[1],
film: mockFilms[0],
fastest: 'f/3.5 - 1/100',
slowest: 'f/22 - 1/2.5',
iso: '400',
nd: 'None',
ev: mockPhotoEv100 + 2,
);
/// Set another film and another ISO
await tester.openPickerAndSelect<IsoValuePicker, IsoValue>('200');
await tester.openPickerAndSelect<FilmPicker, Film>(mockFilms[1].name);
await _expectMeteringStateAndMeasure(
tester,
equipmentProfile: mockEquipmentProfiles[1],
film: mockFilms[1],
fastest: 'f/3.5 - 1/50',
slowest: 'f/22 - 1/1.3',
iso: '200',
nd: 'None',
ev: mockPhotoEv100 + 1,
);
},
);
}
extension EquipmentProfileActions on WidgetTester {
Future<void> expandEquipmentProfileContainer(String name) async {
await tap(find.text(name));
await pump(Dimens.durationM);
}
Future<void> setProfileName(String name) async {
await enterText(find.byType(TextField), name);
await pump();
await tapSaveButton();
}
Future<void> setIsoValues(int profileIndex, List<IsoValue> values) =>
_openAndSetDialogFilterValues<IsoValue>(profileIndex, S.current.isoValues, values);
Future<void> setNdValues(int profileIndex, List<NdValue> values) =>
_openAndSetDialogFilterValues<NdValue>(profileIndex, S.current.ndFilters, values);
Future<void> _openAndSetDialogFilterValues<T extends PhotographyValue>(
int profileIndex,
String listTileTitle,
List<T> valuesToSelect, {
bool deselectAll = true,
}) async {
await tap(find.text(listTileTitle).at(profileIndex));
await pumpAndSettle();
await setDialogFilterValues(valuesToSelect, deselectAll: deselectAll);
}
Future<void> setApertureValues(int profileIndex, List<ApertureValue> values) =>
_setDialogRangePickerValues<ApertureValue>(profileIndex, S.current.apertureValues, values);
Future<void> setShutterSpeedValues(int profileIndex, List<ShutterSpeedValue> values) =>
_setDialogRangePickerValues<ShutterSpeedValue>(profileIndex, S.current.shutterSpeedValues, values);
}
extension on WidgetTester {
Future<void> openPickerAndSelect<P extends Widget, V>(String valueToSelect) async {
await openAnimatedPicker<P>();
await tapDescendantTextOf<DialogPicker<V>>(valueToSelect);
await tapSelectButton();
}
Future<void> setDialogFilterValues<T>(
List<T> valuesToSelect, {
bool deselectAll = true,
}) async {
if (deselectAll) {
await tap(find.byIcon(Icons.deselect));
await pump();
}
for (final value in valuesToSelect) {
final listTile = find.descendant(of: find.byType(CheckboxListTile), matching: find.text(value.toString()));
await scrollUntilVisible(
listTile,
56,
scrollable: find.descendant(of: find.byType(DialogFilter<T>), matching: find.byType(Scrollable)),
);
await tap(listTile);
await pump();
}
await tapSaveButton();
}
Future<void> _setDialogRangePickerValues<T extends PhotographyValue>(
int profileIndex,
String listTileTitle,
List<T> valuesToSelect,
) async {
await tap(find.text(listTileTitle).at(profileIndex));
await pumpAndSettle();
final dialog = widget<DialogRangePicker<T>>(find.byType(DialogRangePicker<T>));
final sliderFinder = find.byType(RangeSlider);
final divisions = widget<RangeSlider>(sliderFinder).divisions!;
final trackWidth = getSize(sliderFinder).width - (2 * Dimens.paddingL);
final trackStep = trackWidth / divisions;
final start = valuesToSelect.first;
final oldStart = dialog.values.indexWhere((e) => e.value == dialog.selectedValues.first.value) * trackStep;
final newStart = dialog.values.indexWhere((e) => e.value == start.value) * trackStep;
await dragFrom(
getTopLeft(sliderFinder) + Offset(Dimens.paddingL + oldStart, getSize(sliderFinder).height / 2),
Offset(newStart - oldStart, 0),
);
await pump();
final end = valuesToSelect.last;
final oldEnd = dialog.values.indexWhere((e) => e.value == dialog.selectedValues.last.value) * trackStep;
final newEnd = dialog.values.indexWhere((e) => e.value == end.value) * trackStep;
await dragFrom(
getTopLeft(sliderFinder) + Offset(Dimens.paddingL + oldEnd, getSize(sliderFinder).height / 2),
Offset(newEnd - oldEnd, 0),
);
await pump();
await tapSaveButton();
}
}
Future<void> _expectMeteringState(
WidgetTester tester, {
required EquipmentProfile equipmentProfile,
required Film film,
required String fastest,
required String slowest,
required String iso,
required String nd,
required double ev,
String? reason,
}) async {
expectPickerTitle<EquipmentProfilePicker>(equipmentProfile.name);
expectPickerTitle<FilmPicker>(film.name);
expectExtremeExposurePairs(fastest, slowest);
expectPickerTitle<IsoValuePicker>(iso);
expectPickerTitle<NdValuePicker>(nd);
expectExposurePairsListItem(tester, fastest.split(' - ')[0], fastest.split(' - ')[1]);
await tester.scrollToTheLastExposurePair(equipmentProfile: equipmentProfile);
expectExposurePairsListItem(tester, slowest.split(' - ')[0], slowest.split(' - ')[1]);
expectMeasureButton(ev);
}
Future<void> _expectMeteringStateAndMeasure(
WidgetTester tester, {
required EquipmentProfile equipmentProfile,
required Film film,
required String fastest,
required String slowest,
required String iso,
required String nd,
required double ev,
}) async {
await _expectMeteringState(
tester,
equipmentProfile: equipmentProfile,
film: film,
fastest: fastest,
slowest: slowest,
iso: iso,
nd: nd,
ev: ev,
);
await tester.takePhoto();
await _expectMeteringState(
tester,
equipmentProfile: equipmentProfile,
film: film,
fastest: fastest,
slowest: slowest,
iso: iso,
nd: nd,
ev: ev,
reason:
'Metering screen state must be the same before and after the measurement assuming that the scene is exactly the same.',
);
}
void expectMeasureButton(double ev) {
find.descendant(
of: find.byType(MeteringMeasureButton),
matching: find.text('${ev.toStringAsFixed(1)}\n${S.current.ev}'),
);
}

View file

@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../integration_test/utils/widget_tester_actions.dart';
import 'mocks/paid_features_mock.dart';
import 'utils/expectations.dart';
@isTestGroup
void testToggleLayoutFeatures(String description) {
group(
description,
() {
setUp(() {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
});
});
testWidgets(
'Equipment profile picker',
(tester) async {
await tester.pumpApplication(selectedEquipmentProfileId: mockEquipmentProfiles.first.id);
await tester.takePhoto();
expectPickerTitle<EquipmentProfilePicker>(mockEquipmentProfiles.first.name);
expectExtremeExposurePairs('f/1.8 - 1/100', 'f/16 - 1/1.3');
expectExposurePairsListItem(tester, 'f/1.8', '1/100');
await tester.scrollToTheLastExposurePair(equipmentProfile: mockEquipmentProfiles.first);
expectExposurePairsListItem(tester, 'f/16', '1/1.3');
// Disable layout feature
await tester.toggleLayoutFeature(S.current.meteringScreenLayoutHintEquipmentProfiles);
expect(
find.byType(EquipmentProfilePicker),
findsNothing,
reason:
'Equipment profile picker must be hidden from the metering screen when the corresponding layout feature is disabled.',
);
expectExtremeExposurePairs(
'f/1.0 - 1/320',
'f/45 - 6"',
reason: 'Aperture and shutter speed ranges must be reset to default values when equipment profile is reset',
);
expectExposurePairsListItem(
tester,
'f/1.0',
'1/320',
reason:
'Aperture and shutter speed ranges must be reset to default values when equipment profile is reset.',
);
await tester.scrollToTheLastExposurePair();
expectExposurePairsListItem(
tester,
'f/45',
'6"',
reason:
'Aperture and shutter speed ranges must be reset to default values when equipment profile is reset.',
);
// Enable layout feature
await tester.toggleLayoutFeature(S.current.meteringScreenLayoutHintEquipmentProfiles);
expectPickerTitle<EquipmentProfilePicker>(
S.current.none,
reason: 'Equipment profile must remain unselected when the corresponding layout feature is re-enabled.',
);
},
);
testWidgets(
'Extreme exposure pairs container',
(tester) async {
await tester.pumpApplication();
await tester.takePhoto();
expectExtremeExposurePairs('f/1.0 - 1/320', 'f/45 - 6"');
expectExposurePairsListItem(tester, 'f/1.0', '1/320');
await tester.scrollToTheLastExposurePair();
expectExposurePairsListItem(tester, 'f/45', '6"');
// Disable layout feature
await tester.toggleLayoutFeature(S.current.meteringScreenFeatureExtremeExposurePairs);
expect(
find.byType(ExtremeExposurePairsContainer),
findsNothing,
reason:
'Extreme exposure pairs container must be hidden from the metering screen when the corresponding layout feature is disabled.',
);
expectExposurePairsListItem(
tester,
'f/1.0',
'1/320',
reason:
'Exposure pairs list must not be affected by the visibility of the extreme exposure pairs container.',
);
await tester.scrollToTheLastExposurePair();
expectExposurePairsListItem(
tester,
'f/45',
'6"',
reason:
'Exposure pairs list must not be affected by the visibility of the extreme exposure pairs container.',
);
// Enable layout feature
await tester.toggleLayoutFeature(S.current.meteringScreenFeatureExtremeExposurePairs);
expectExtremeExposurePairs(
'f/1.0 - 1/320',
'f/45 - 6"',
reason:
'Exposure pairs list must not be affected by the visibility of the extreme exposure pairs container.',
);
},
);
testWidgets(
'Film picker',
(tester) async {
await tester.pumpApplication(selectedFilm: mockFilms.first);
await tester.takePhoto();
expectPickerTitle<FilmPicker>(mockFilms.first.name);
expectExtremeExposurePairs('f/1.0 - 1/320', 'f/45 - 12"');
expectExposurePairsListItem(tester, 'f/1.0', '1/320');
await tester.scrollToTheLastExposurePair();
expectExposurePairsListItem(tester, 'f/45', '12"');
// Disable layout feature
await tester.toggleLayoutFeature(S.current.meteringScreenFeatureFilmPicker);
expect(
find.byType(FilmPicker),
findsNothing,
reason:
'Film picker must be hidden from the metering screen when the corresponding layout feature is disabled.',
);
expectExtremeExposurePairs(
'f/1.0 - 1/320',
'f/45 - 6"',
reason: 'Shutter speed must not be affected by reciprocity when film is discarded.',
);
expectExposurePairsListItem(
tester,
'f/1.0',
'1/320',
reason: 'Shutter speed must not be affected by reciprocity when film is discarded.',
);
await tester.scrollToTheLastExposurePair();
expectExposurePairsListItem(
tester,
'f/45',
'6"',
reason: 'Shutter speed must not be affected by reciprocity when film is discarded.',
);
// Enable layout feature
await tester.toggleLayoutFeature(S.current.meteringScreenFeatureFilmPicker);
expectPickerTitle<FilmPicker>(
S.current.none,
reason: 'Film must remain unselected when the corresponding layout feature is re-enabled.',
);
},
);
},
);
}
extension on WidgetTester {
Future<void> toggleLayoutFeature(String feature) async {
await openSettings();
await tapDescendantTextOf<SettingsScreen>(S.current.meteringScreenLayout);
await tapDescendantTextOf<SwitchListTile>(feature);
await tapSaveButton();
await navigatorPop();
}
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
@visibleForTesting
class MockIAPProductsProvider extends StatefulWidget {
final bool initialyPurchased;
final Widget child;
const MockIAPProductsProvider({this.initialyPurchased = true, required this.child, super.key});
static MockIAPProductsProviderState of(BuildContext context) => MockIAPProductsProvider.maybeOf(context)!;
static MockIAPProductsProviderState? maybeOf(BuildContext context) {
return context.findAncestorStateOfType<MockIAPProductsProviderState>();
}
@override
State<MockIAPProductsProvider> createState() => MockIAPProductsProviderState();
}
class MockIAPProductsProviderState extends State<MockIAPProductsProvider> {
late bool _purchased = widget.initialyPurchased;
@override
Widget build(BuildContext context) {
return IAPProducts(
products: List.from([
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: _purchased ? IAPProductStatus.purchased : IAPProductStatus.purchasable,
)
]),
child: widget.child,
);
}
void buy() {
_purchased = true;
setState(() {});
}
void clearPurchases() {
_purchased = false;
setState(() {});
}
}

View file

@ -8,9 +8,9 @@ import 'package:mocktail/mocktail.dart';
class _MockIAPStorageService extends Mock implements IAPStorageService {} class _MockIAPStorageService extends Mock implements IAPStorageService {}
class MockIAPProviders extends StatefulWidget { class MockIAPProviders extends StatefulWidget {
final List<EquipmentProfile> equipmentProfiles; final List<EquipmentProfile>? equipmentProfiles;
final String selectedEquipmentProfileId; final String selectedEquipmentProfileId;
final List<Film> films; final List<Film>? films;
final Film selectedFilm; final Film selectedFilm;
final Widget child; final Widget child;
@ -34,9 +34,9 @@ class _MockIAPProvidersState extends State<MockIAPProviders> {
void initState() { void initState() {
super.initState(); super.initState();
mockIAPStorageService = _MockIAPStorageService(); mockIAPStorageService = _MockIAPStorageService();
when(() => mockIAPStorageService.equipmentProfiles).thenReturn(mockEquipmentProfiles); when(() => mockIAPStorageService.equipmentProfiles).thenReturn(widget.equipmentProfiles ?? mockEquipmentProfiles);
when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId);
when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); when(() => mockIAPStorageService.filmsInUse).thenReturn(widget.films ?? mockFilms);
when(() => mockIAPStorageService.selectedFilm).thenReturn(widget.selectedFilm); when(() => mockIAPStorageService.selectedFilm).thenReturn(widget.selectedFilm);
} }
@ -92,13 +92,34 @@ final mockEquipmentProfiles = [
IsoValue(3200, StopType.full), IsoValue(3200, StopType.full),
], ],
), ),
const EquipmentProfile( EquipmentProfile(
id: '2', id: '2',
name: 'Praktica + Jupiter', name: 'Praktica + Jupiter',
apertureValues: ApertureValue.values, apertureValues: ApertureValue.values.sublist(
ndValues: NdValue.values, ApertureValue.values.indexOf(const ApertureValue(3.5, StopType.third)),
shutterSpeedValues: ShutterSpeedValue.values, ApertureValue.values.indexOf(const ApertureValue(22, StopType.full)) + 1,
isoValues: IsoValue.values, ),
ndValues: const [
NdValue(0),
NdValue(2),
NdValue(4),
NdValue(8),
],
shutterSpeedValues: ShutterSpeedValue.values.sublist(
ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)),
ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1,
),
isoValues: const [
IsoValue(50, StopType.full),
IsoValue(100, StopType.full),
IsoValue(200, StopType.full),
IsoValue(250, StopType.third),
IsoValue(400, StopType.full),
IsoValue(500, StopType.third),
IsoValue(800, StopType.full),
IsoValue(1600, StopType.full),
IsoValue(3200, StopType.full),
],
), ),
]; ];

View file

@ -0,0 +1,109 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../integration_test/utils/widget_tester_actions.dart';
import 'mocks/iap_products_mock.dart';
@isTest
void testPurchases(String description) {
testWidgets(
description,
(tester) async {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.showEv100Key: true,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
});
await tester.pumpApplication(productStatus: IAPProductStatus.purchasable);
await tester.takePhoto();
/// Expect the bare minimum free functionallity
_expectProMeteringScreen(enabled: false);
/// Check, that premium settings are disabled
await tester.openSettings();
await _expectProSettingsScreen(tester, enabled: false);
/// Make purchase
(tester.state(find.byType(MockIAPProductsProvider)) as MockIAPProductsProviderState).buy();
await tester.pumpAndSettle();
/// Check, that premium settings are enabled
await _expectProSettingsScreen(tester, enabled: true);
/// Expect, that all the premium controls are now available to user
await tester.navigatorPop();
_expectProMeteringScreen(enabled: true);
/// Refund
(tester.state(find.byType(MockIAPProductsProvider)) as MockIAPProductsProviderState).clearPurchases();
await tester.pumpAndSettle();
/// Expect the bare minimum free functionallity
_expectProMeteringScreen(enabled: false);
/// Check, that premium settings are disabled
await tester.openSettings();
await _expectProSettingsScreen(tester, enabled: false);
},
);
}
void _expectProMeteringScreen({required bool enabled}) {
expect(find.byType(EquipmentProfilePicker), enabled ? findsOneWidget : findsNothing);
expect(find.byType(ExtremeExposurePairsContainer), findsOneWidget);
expect(find.byType(FilmPicker), enabled ? findsOneWidget : findsNothing);
expect(find.byType(IsoValuePicker), findsOneWidget);
expect(find.byType(NdValuePicker), findsOneWidget);
expect(
find.descendant(
of: find.byType(MeteringMeasureButton),
matching: find.byWidgetPredicate((widget) => widget is Text && widget.data!.contains('\u2081\u2080\u2080')),
),
enabled ? findsOneWidget : findsNothing,
);
}
Future<void> _expectProSettingsScreen(WidgetTester tester, {required bool enabled}) async {
void expectDisabled(String title, bool disabled) {
find.ancestor(
of: find.text(title),
matching: find.byWidgetPredicate((widget) => widget is Disable && widget.disable == disabled),
);
}
expectDisabled(S.current.showEv100, !enabled);
expectDisabled(S.current.equipmentProfiles, !enabled);
expectDisabled(S.current.filmsInUse, !enabled);
expectDisabled(S.current.cameraFeatures, !enabled);
await tester.tapDescendantTextOf<SettingsScreen>(S.current.meteringScreenLayout);
expectDisabled(S.current.meteringScreenLayoutHintEquipmentProfiles, !enabled);
expectDisabled(S.current.meteringScreenFeatureExtremeExposurePairs, false); // must be always enabled
expectDisabled(S.current.meteringScreenFeatureFilmPicker, !enabled);
await tester.tapCancelButton();
}

View file

@ -0,0 +1,13 @@
import 'package:integration_test/integration_test.dart';
import 'e2e_test.dart';
import 'metering_screen_layout_test.dart';
import 'purchases_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testPurchases('Purchase & refund premium features');
testToggleLayoutFeatures('Toggle metering screen layout features');
testE2E('e2e');
}

View file

@ -0,0 +1,35 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
void expectPickerTitle<P extends Widget>(String title, {String? reason}) {
expect(find.descendant(of: find.byType(P), matching: find.text(title)), findsOneWidget, reason: reason);
}
void expectExtremeExposurePairs(String fastest, String slowest, {String? reason}) {
final pickerFinder = find.byType(ExtremeExposurePairsContainer);
expect(find.descendant(of: pickerFinder, matching: find.text(fastest)), findsOneWidget, reason: reason);
expect(find.descendant(of: pickerFinder, matching: find.text(slowest)), findsOneWidget, reason: reason);
}
void expectExposurePairsListItem(WidgetTester tester, String aperture, String shutterSpeed, {String? reason}) {
Key? findKey<T extends PhotographyStopValue<num>>(String value) => tester
.widget<Row>(
find.ancestor(
of: find.ancestor(
of: find.text(value),
matching: find.byType(ExposurePairsListItem<T>),
),
matching: find.descendant(of: find.byType(ExposurePairsList), matching: find.byType(Row)),
),
)
.key;
expect(
findKey<ApertureValue>(aperture),
findKey<ShutterSpeedValue>(shutterSpeed),
reason: reason,
);
}

View file

@ -6,30 +6,34 @@ import 'package:lightmeter/environment.dart';
import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart';
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/screen_metering.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import '../mocks/iap_products_mock.dart';
import '../mocks/paid_features_mock.dart'; import '../mocks/paid_features_mock.dart';
import 'platform_channel_mock.dart'; import 'platform_channel_mock.dart';
const mockPhotoEv100 = 8.3;
extension WidgetTesterCommonActions on WidgetTester { extension WidgetTesterCommonActions on WidgetTester {
Future<void> pumpApplication({ Future<void> pumpApplication({
IAPProductStatus productStatus = IAPProductStatus.purchased, IAPProductStatus productStatus = IAPProductStatus.purchased,
List<EquipmentProfile>? equipmentProfiles,
String selectedEquipmentProfileId = '', String selectedEquipmentProfileId = '',
List<Film>? films,
Film selectedFilm = const Film.other(), Film selectedFilm = const Film.other(),
}) async { }) async {
await pumpWidget( await pumpWidget(
IAPProducts( MockIAPProductsProvider(
products: [ initialyPurchased: productStatus == IAPProductStatus.purchased,
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: productStatus,
),
],
child: ApplicationWrapper( child: ApplicationWrapper(
const Environment.dev(), const Environment.dev(),
child: MockIAPProviders( child: MockIAPProviders(
equipmentProfiles: equipmentProfiles,
selectedEquipmentProfileId: selectedEquipmentProfileId, selectedEquipmentProfileId: selectedEquipmentProfileId,
films: films,
selectedFilm: selectedFilm, selectedFilm: selectedFilm,
child: const Application(), child: const Application(),
), ),
@ -57,6 +61,16 @@ extension WidgetTesterCommonActions on WidgetTester {
await tap(find.byType(T)); await tap(find.byType(T));
await pumpAndSettle(Dimens.durationL); await pumpAndSettle(Dimens.durationL);
} }
Future<void> openSettings() async {
await tap(find.byTooltip(S.current.tooltipOpenSettings));
await pumpAndSettle();
}
Future<void> navigatorPop() async {
(state(find.byType(Navigator)) as NavigatorState).pop();
await pumpAndSettle(Dimens.durationML);
}
} }
extension WidgetTesterListTileActions on WidgetTester { extension WidgetTesterListTileActions on WidgetTester {
@ -83,3 +97,22 @@ extension WidgetTesterTextButtonActions on WidgetTester {
await pumpAndSettle(); await pumpAndSettle();
} }
} }
extension WidgetTesterExposurePairsListActions on WidgetTester {
Future<void> scrollToTheLastExposurePair({
double ev = mockPhotoEv100,
StopType stopType = StopType.third,
EquipmentProfile equipmentProfile = defaultEquipmentProfile,
}) async {
final exposurePairs = MeteringContainerBuidler.buildExposureValues(
ev,
StopType.third,
equipmentProfile,
);
await scrollUntilVisible(
find.byWidgetPredicate((widget) => widget is Row && widget.key == ValueKey(exposurePairs.length - 1)),
56,
scrollable: find.descendant(of: find.byType(ExposurePairsList), matching: find.byType(Scrollable)),
);
}
}

View file

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
platform :ios, '11.0' platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@ -38,6 +38,10 @@ post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
end
# Start of the permission_handler configuration # Start of the permission_handler configuration
target.build_configurations.each do |config| target.build_configurations.each do |config|
# https://github.com/CocoaPods/CocoaPods/issues/12012 # https://github.com/CocoaPods/CocoaPods/issues/12012

View file

@ -407,6 +407,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -544,6 +545,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -575,6 +577,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -658,6 +661,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -738,6 +742,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -815,6 +820,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View file

@ -52,6 +52,7 @@ dev_dependencies:
integration_test: integration_test:
sdk: flutter sdk: flutter
lint: 2.1.2 lint: 2.1.2
meta: 1.9.1
mocktail: 0.3.0 mocktail: 0.3.0
test: 1.24.1 test: 1.24.1

View file

@ -1,8 +1,5 @@
import 'package:integration_test/integration_test_driver_extended.dart'; import 'package:integration_test/integration_test_driver_extended.dart';
import 'utils/grant_camera_permission.dart';
Future<void> main() async { Future<void> main() async {
await grantCameraPermission();
await integrationDriver(); await integrationDriver();
} }