From 778755871372d4df2f98afa2ca8eec8b79ff898a Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Wed, 13 Mar 2024 15:34:26 +0100 Subject: [PATCH] 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 --- .github/scripts/restore_from_base64.sh | 2 +- .github/workflows/run_integration_tests.yml | 56 ++++ android/app/build.gradle | 2 +- integration_test/README.md | 18 ++ integration_test/e2e_test.dart | 302 ++++++++++++++++++ .../metering_screen_layout_test.dart | 191 +++++++++++ integration_test/mocks/iap_products_mock.dart | 45 +++ .../mocks/paid_features_mock.dart | 39 ++- integration_test/purchases_test.dart | 109 +++++++ integration_test/run_all_tests.dart | 13 + integration_test/utils/expectations.dart | 35 ++ .../utils/widget_tester_actions.dart | 47 ++- ios/Podfile | 6 +- ios/Runner.xcodeproj/project.pbxproj | 6 + pubspec.yaml | 1 + test_driver/integration_driver.dart | 3 - 16 files changed, 853 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/run_integration_tests.yml create mode 100644 integration_test/README.md create mode 100644 integration_test/e2e_test.dart create mode 100644 integration_test/metering_screen_layout_test.dart create mode 100644 integration_test/mocks/iap_products_mock.dart create mode 100644 integration_test/purchases_test.dart create mode 100644 integration_test/run_all_tests.dart create mode 100644 integration_test/utils/expectations.dart diff --git a/.github/scripts/restore_from_base64.sh b/.github/scripts/restore_from_base64.sh index bd3daa5..cceaf66 100644 --- a/.github/scripts/restore_from_base64.sh +++ b/.github/scripts/restore_from_base64.sh @@ -11,4 +11,4 @@ if [[ ! -n "$filename" ]]; then exit 1 fi -echo -n "$content" | base64 --decode --output "$filename" +base64 -d <<< "$content" > "$filename" diff --git a/.github/workflows/run_integration_tests.yml b/.github/workflows/run_integration_tests.yml new file mode 100644 index 0000000..ba4d6fb --- /dev/null +++ b/.github/workflows/run_integration_tests.yml @@ -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 diff --git a/android/app/build.gradle b/android/app/build.gradle index be85756..05c341c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -34,7 +34,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdkVersion 34 ndkVersion flutter.ndkVersion compileOptions { diff --git a/integration_test/README.md b/integration_test/README.md new file mode 100644 index 0000000..ebc3158 --- /dev/null +++ b/integration_test/README.md @@ -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 +``` diff --git a/integration_test/e2e_test.dart b/integration_test/e2e_test.dart new file mode 100644 index 0000000..39306a4 --- /dev/null +++ b/integration_test/e2e_test.dart @@ -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(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([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(mockEquipmentProfiles[0].name); + await tester.openPickerAndSelect(mockFilms[0].name); + await tester.openPickerAndSelect('400'); + expectPickerTitle(mockEquipmentProfiles[0].name); + expectPickerTitle(mockFilms[0].name); + expectPickerTitle('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('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(mockEquipmentProfiles[1].name); + await tester.openPickerAndSelect('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('200'); + await tester.openPickerAndSelect(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 expandEquipmentProfileContainer(String name) async { + await tap(find.text(name)); + await pump(Dimens.durationM); + } + + Future setProfileName(String name) async { + await enterText(find.byType(TextField), name); + await pump(); + await tapSaveButton(); + } + + Future setIsoValues(int profileIndex, List values) => + _openAndSetDialogFilterValues(profileIndex, S.current.isoValues, values); + Future setNdValues(int profileIndex, List values) => + _openAndSetDialogFilterValues(profileIndex, S.current.ndFilters, values); + Future _openAndSetDialogFilterValues( + int profileIndex, + String listTileTitle, + List valuesToSelect, { + bool deselectAll = true, + }) async { + await tap(find.text(listTileTitle).at(profileIndex)); + await pumpAndSettle(); + await setDialogFilterValues(valuesToSelect, deselectAll: deselectAll); + } + + Future setApertureValues(int profileIndex, List values) => + _setDialogRangePickerValues(profileIndex, S.current.apertureValues, values); + + Future setShutterSpeedValues(int profileIndex, List values) => + _setDialogRangePickerValues(profileIndex, S.current.shutterSpeedValues, values); +} + +extension on WidgetTester { + Future openPickerAndSelect

(String valueToSelect) async { + await openAnimatedPicker

(); + await tapDescendantTextOf>(valueToSelect); + await tapSelectButton(); + } + + Future setDialogFilterValues( + List 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), matching: find.byType(Scrollable)), + ); + await tap(listTile); + await pump(); + } + await tapSaveButton(); + } + + Future _setDialogRangePickerValues( + int profileIndex, + String listTileTitle, + List valuesToSelect, + ) async { + await tap(find.text(listTileTitle).at(profileIndex)); + await pumpAndSettle(); + + final dialog = widget>(find.byType(DialogRangePicker)); + final sliderFinder = find.byType(RangeSlider); + final divisions = widget(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 _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(equipmentProfile.name); + expectPickerTitle(film.name); + expectExtremeExposurePairs(fastest, slowest); + expectPickerTitle(iso); + expectPickerTitle(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 _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}'), + ); +} diff --git a/integration_test/metering_screen_layout_test.dart b/integration_test/metering_screen_layout_test.dart new file mode 100644 index 0000000..3775870 --- /dev/null +++ b/integration_test/metering_screen_layout_test.dart @@ -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(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( + 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(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( + S.current.none, + reason: 'Film must remain unselected when the corresponding layout feature is re-enabled.', + ); + }, + ); + }, + ); +} + +extension on WidgetTester { + Future toggleLayoutFeature(String feature) async { + await openSettings(); + await tapDescendantTextOf(S.current.meteringScreenLayout); + await tapDescendantTextOf(feature); + await tapSaveButton(); + await navigatorPop(); + } +} diff --git a/integration_test/mocks/iap_products_mock.dart b/integration_test/mocks/iap_products_mock.dart new file mode 100644 index 0000000..1a67c69 --- /dev/null +++ b/integration_test/mocks/iap_products_mock.dart @@ -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(); + } + + @override + State createState() => MockIAPProductsProviderState(); +} + +class MockIAPProductsProviderState extends State { + 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(() {}); + } +} diff --git a/integration_test/mocks/paid_features_mock.dart b/integration_test/mocks/paid_features_mock.dart index 1038694..03402dd 100644 --- a/integration_test/mocks/paid_features_mock.dart +++ b/integration_test/mocks/paid_features_mock.dart @@ -8,9 +8,9 @@ import 'package:mocktail/mocktail.dart'; class _MockIAPStorageService extends Mock implements IAPStorageService {} class MockIAPProviders extends StatefulWidget { - final List equipmentProfiles; + final List? equipmentProfiles; final String selectedEquipmentProfileId; - final List films; + final List? films; final Film selectedFilm; final Widget child; @@ -34,9 +34,9 @@ class _MockIAPProvidersState extends State { void initState() { super.initState(); mockIAPStorageService = _MockIAPStorageService(); - when(() => mockIAPStorageService.equipmentProfiles).thenReturn(mockEquipmentProfiles); + when(() => mockIAPStorageService.equipmentProfiles).thenReturn(widget.equipmentProfiles ?? mockEquipmentProfiles); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId); - when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); + when(() => mockIAPStorageService.filmsInUse).thenReturn(widget.films ?? mockFilms); when(() => mockIAPStorageService.selectedFilm).thenReturn(widget.selectedFilm); } @@ -92,13 +92,34 @@ final mockEquipmentProfiles = [ IsoValue(3200, StopType.full), ], ), - const EquipmentProfile( + EquipmentProfile( id: '2', name: 'Praktica + Jupiter', - apertureValues: ApertureValue.values, - ndValues: NdValue.values, - shutterSpeedValues: ShutterSpeedValue.values, - isoValues: IsoValue.values, + apertureValues: ApertureValue.values.sublist( + ApertureValue.values.indexOf(const ApertureValue(3.5, StopType.third)), + ApertureValue.values.indexOf(const ApertureValue(22, StopType.full)) + 1, + ), + 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), + ], ), ]; diff --git a/integration_test/purchases_test.dart b/integration_test/purchases_test.dart new file mode 100644 index 0000000..8d91fce --- /dev/null +++ b/integration_test/purchases_test.dart @@ -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 _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(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(); +} diff --git a/integration_test/run_all_tests.dart b/integration_test/run_all_tests.dart new file mode 100644 index 0000000..112e60e --- /dev/null +++ b/integration_test/run_all_tests.dart @@ -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'); +} diff --git a/integration_test/utils/expectations.dart b/integration_test/utils/expectations.dart new file mode 100644 index 0000000..6fb6b10 --- /dev/null +++ b/integration_test/utils/expectations.dart @@ -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

(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>(String value) => tester + .widget( + find.ancestor( + of: find.ancestor( + of: find.text(value), + matching: find.byType(ExposurePairsListItem), + ), + matching: find.descendant(of: find.byType(ExposurePairsList), matching: find.byType(Row)), + ), + ) + .key; + expect( + findKey(aperture), + findKey(shutterSpeed), + reason: reason, + ); +} diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart index 72bc328..6ae0194 100644 --- a/integration_test/utils/widget_tester_actions.dart +++ b/integration_test/utils/widget_tester_actions.dart @@ -6,30 +6,34 @@ import 'package:lightmeter/environment.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/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_resources/m3_lightmeter_resources.dart'; +import '../mocks/iap_products_mock.dart'; import '../mocks/paid_features_mock.dart'; import 'platform_channel_mock.dart'; +const mockPhotoEv100 = 8.3; + extension WidgetTesterCommonActions on WidgetTester { Future pumpApplication({ IAPProductStatus productStatus = IAPProductStatus.purchased, + List? equipmentProfiles, String selectedEquipmentProfileId = '', + List? films, Film selectedFilm = const Film.other(), }) async { await pumpWidget( - IAPProducts( - products: [ - IAPProduct( - storeId: IAPProductType.paidFeatures.storeId, - status: productStatus, - ), - ], + MockIAPProductsProvider( + initialyPurchased: productStatus == IAPProductStatus.purchased, child: ApplicationWrapper( const Environment.dev(), child: MockIAPProviders( + equipmentProfiles: equipmentProfiles, selectedEquipmentProfileId: selectedEquipmentProfileId, + films: films, selectedFilm: selectedFilm, child: const Application(), ), @@ -57,6 +61,16 @@ extension WidgetTesterCommonActions on WidgetTester { await tap(find.byType(T)); await pumpAndSettle(Dimens.durationL); } + + Future openSettings() async { + await tap(find.byTooltip(S.current.tooltipOpenSettings)); + await pumpAndSettle(); + } + + Future navigatorPop() async { + (state(find.byType(Navigator)) as NavigatorState).pop(); + await pumpAndSettle(Dimens.durationML); + } } extension WidgetTesterListTileActions on WidgetTester { @@ -83,3 +97,22 @@ extension WidgetTesterTextButtonActions on WidgetTester { await pumpAndSettle(); } } + +extension WidgetTesterExposurePairsListActions on WidgetTester { + Future 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)), + ); + } +} diff --git a/ios/Podfile b/ios/Podfile index e7974fe..5588975 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # 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. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -38,6 +38,10 @@ post_install do |installer| installer.pods_project.targets.each do |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 target.build_configurations.each do |config| # https://github.com/CocoaPods/CocoaPods/issues/12012 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c8d24d4..801a43b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -407,6 +407,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -544,6 +545,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -575,6 +577,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -658,6 +661,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -738,6 +742,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -815,6 +820,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/pubspec.yaml b/pubspec.yaml index 5792aed..56bac35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dev_dependencies: integration_test: sdk: flutter lint: 2.1.2 + meta: 1.9.1 mocktail: 0.3.0 test: 1.24.1 diff --git a/test_driver/integration_driver.dart b/test_driver/integration_driver.dart index 3d79aac..82b5a06 100644 --- a/test_driver/integration_driver.dart +++ b/test_driver/integration_driver.dart @@ -1,8 +1,5 @@ import 'package:integration_test/integration_test_driver_extended.dart'; -import 'utils/grant_camera_permission.dart'; - Future main() async { - await grantCameraPermission(); await integrationDriver(); }