Skip to content

Commit

Permalink
ML-160 Integration tests (#161)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
vodemn committed Mar 13, 2024
1 parent 134af8a commit 7787558
Show file tree
Hide file tree
Showing 16 changed files with 853 additions and 22 deletions.
2 changes: 1 addition & 1 deletion .github/scripts/restore_from_base64.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ if [[ ! -n "$filename" ]]; then
exit 1
fi

echo -n "$content" | base64 --decode --output "$filename"
base64 -d <<< "$content" > "$filename"
56 changes: 56 additions & 0 deletions .github/workflows/run_integration_tests.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions integration_test/README.md
Original file line number Diff line number Diff line change
@@ -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
```
302 changes: 302 additions & 0 deletions integration_test/e2e_test.dart
Original file line number Diff line number Diff line change
@@ -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}'),
);
}
Loading

0 comments on commit 7787558

Please sign in to comment.