diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index 75ef37e..eb0a546 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -12,9 +12,9 @@ on: branches: ["main"] jobs: - analyze_and_test: + analyze-and-test: name: Analyze & test - runs-on: macos-11 + runs-on: macos-latest timeout-minutes: 10 steps: - uses: 8BitJonny/gh-get-current-pr@2.2.0 @@ -54,11 +54,11 @@ jobs: run: flutter analyze lib --fatal-infos - name: Run tests - run: flutter test + run: flutter test --dart-define cameraStubImage=assets/camera_stub_image.jpg - name: Analyze project source with stub if: steps.override-iap.conclusion != 'success' run: | bash ./.github/scripts/stub_iap.sh flutter pub get - flutter analyze lib --fatal-infos \ No newline at end of file + flutter analyze lib --fatal-infos diff --git a/.github/workflows/run_golden_tests.yml b/.github/workflows/run_golden_tests.yml new file mode 100644 index 0000000..1cfaf89 --- /dev/null +++ b/.github/workflows/run_golden_tests.yml @@ -0,0 +1,62 @@ +# 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 golden tests + +on: + workflow_dispatch: + inputs: + update-goldens: + type: boolean + description: Update goldens + default: false + +jobs: + run-golden-tests: + name: Run golden tests + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: "3.13.9" + + - name: Prepare app + run: | + flutter --version + flutter pub get + flutter pub run intl_utils:generate + + - name: Run tests + env: + UPDATE_GOLDENS: ${{inputs.update-goldens && '--update-goldens' || '' }}ƒ + run: | + goldens=$(find ./test -name "*_golden_test.dart" -print) + for f in $goldens; do + flutter test "$f"\ + --dart-define cameraStubImage=assets/camera_stub_image.jpg \ + $UPDATE_GOLDENS + done + + - name: Commit changes + if: ${{ inputs.update-goldens }} + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add -A + git commit -m "Updated goldens" + + - name: Push to main + if: ${{ inputs.update-goldens }} + uses: CasperWA/push-protected@v2 + with: + token: ${{ secrets.PUSH_TO_MAIN_TOKEN }} + branch: ${{ github.ref_name }} + unprotect_reviews: true diff --git a/.gitignore b/.gitignore index 72e16ec..7d5f96c 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ ios/Runner/GoogleService-Info.plist coverage/ test/coverage_helper_test.dart +**/failures/*.png screenshots/generated/ \ No newline at end of file diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..62bd274 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,2 @@ +tags: + golden: \ No newline at end of file diff --git a/lib/data/light_sensor_service.dart b/lib/data/light_sensor_service.dart index a4b95d7..68b2f35 100644 --- a/lib/data/light_sensor_service.dart +++ b/lib/data/light_sensor_service.dart @@ -7,7 +7,7 @@ class LightSensorService { const LightSensorService(this.localPlatform); Future hasSensor() async { - if (!localPlatform.isAndroid) { + if (localPlatform.isIOS) { return false; } try { @@ -18,7 +18,7 @@ class LightSensorService { } Stream luxStream() { - if (!localPlatform.isAndroid) { + if (localPlatform.isIOS) { return const Stream.empty(); } return LightSensor.luxStream(); diff --git a/lib/screens/shared/ruler_slider/widget_slider_ruler.dart b/lib/screens/shared/ruler_slider/widget_slider_ruler.dart index f9f37ff..537824c 100644 --- a/lib/screens/shared/ruler_slider/widget_slider_ruler.dart +++ b/lib/screens/shared/ruler_slider/widget_slider_ruler.dart @@ -77,43 +77,48 @@ class _Ruler extends StatelessWidget { @override Widget build(BuildContext context) { + final mainTicksFontSize = Theme.of(context).textTheme.bodySmall!.fontSize!; return LayoutBuilder( builder: (context, constraints) { - final bool showAllMainTicks = Dimens.cameraSliderHandleArea * mainTicksCount <= constraints.maxHeight; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate( - itemsCount, - (index) { - final bool isMainTick = index % 2 == 0.0; - if (!showAllMainTicks && !isMainTick) { - return const SizedBox(); - } - final bool showValue = (index % (showAllMainTicks ? 2 : 4) == 0.0); - return SizedBox( - height: index == itemsCount - 1 || showValue ? Dimens.cameraSliderHandleArea : 1, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (showValue) - Text( - rulerValueAdapter(index / 2 + min), - style: Theme.of(context).textTheme.bodySmall, + final bool showAllMainTicks = + mainTicksFontSize * mainTicksCount + (1 * mainTicksCount - 1) <= constraints.maxHeight; + return Padding( + padding: EdgeInsets.symmetric(vertical: (Dimens.cameraSliderHandleArea - mainTicksFontSize) / 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate( + itemsCount, + (index) { + final bool isMainTick = index % 2 == 0.0; + if (!showAllMainTicks && !isMainTick) { + return const SizedBox(); + } + final bool showValue = (index % (showAllMainTicks ? 2 : 4) == 0.0); + return SizedBox( + height: index == itemsCount - 1 || showValue ? mainTicksFontSize : 1, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showValue) + Text( + rulerValueAdapter(index / 2 + min), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(width: Dimens.grid4), + ColoredBox( + color: Theme.of(context).colorScheme.onBackground, + child: SizedBox( + height: 1, + width: isMainTick ? Dimens.grid8 : Dimens.grid4, + ), ), - const SizedBox(width: Dimens.grid4), - ColoredBox( - color: Theme.of(context).colorScheme.onBackground, - child: SizedBox( - height: 1, - width: isMainTick ? Dimens.grid8 : Dimens.grid4, - ), - ), - ], - ), - ); - }, - ).reversed.toList(), + ], + ), + ); + }, + ).reversed.toList(), + ), ); }, ); diff --git a/pubspec.yaml b/pubspec.yaml index 1745e90..11387bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dev_dependencies: flutter_native_splash: 2.3.5 flutter_test: sdk: flutter + golden_toolkit: 0.15.0 google_fonts: 3.0.1 integration_test: sdk: flutter diff --git a/test/application_mock.dart b/test/application_mock.dart index a5aecb3..b030428 100644 --- a/test/application_mock.dart +++ b/test/application_mock.dart @@ -1,16 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:light_sensor/light_sensor.dart'; +import 'package:lightmeter/application_wrapper.dart'; +import 'package:lightmeter/data/models/supported_locale.dart'; +import 'package:lightmeter/environment.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/res/theme.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import '../integration_test/mocks/paid_features_mock.dart'; +import '../integration_test/utils/platform_channel_mock.dart'; + /// Provides [MaterialApp] with default theme and "en" localization class WidgetTestApplicationMock extends StatelessWidget { - final IAPProductStatus productStatus; final Widget child; const WidgetTestApplicationMock({ - this.productStatus = IAPProductStatus.purchased, required this.child, super.key, }); @@ -35,3 +42,86 @@ class WidgetTestApplicationMock extends StatelessWidget { ); } } + +class GoldenTestApplicationMock extends StatefulWidget { + final IAPProductStatus productStatus; + final Widget child; + + const GoldenTestApplicationMock({ + this.productStatus = IAPProductStatus.purchased, + required this.child, + super.key, + }); + + @override + State createState() => _GoldenTestApplicationMockState(); +} + +class _GoldenTestApplicationMockState extends State { + @override + void initState() { + super.initState(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + LightSensor.methodChannel, + (methodCall) async { + switch (methodCall.method) { + case "sensor": + return true; + default: + return null; + } + }, + ); + setupLightSensorStreamHandler(); + } + + @override + void dispose() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + LightSensor.methodChannel, + null, + ); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: widget.productStatus, + ), + ], + child: ApplicationWrapper( + const Environment.dev(), + child: MockIAPProviders( + equipmentProfiles: mockEquipmentProfiles, + selectedEquipmentProfileId: mockEquipmentProfiles.first.id, + films: films, + child: Builder( + builder: (context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: UserPreferencesProvider.themeOf(context), + locale: Locale(UserPreferencesProvider.localeOf(context).intlName), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: child!, + ), + home: widget.child, + ); + }, + ), + ), + ), + ); + } +} diff --git a/test/data/light_sensor_service_test.dart b/test/data/light_sensor_service_test.dart index f8be45b..d760cbe 100644 --- a/test/data/light_sensor_service_test.dart +++ b/test/data/light_sensor_service_test.dart @@ -44,19 +44,19 @@ void main() { }); test('true - Android', () async { - when(() => localPlatform.isAndroid).thenReturn(true); + when(() => localPlatform.isIOS).thenReturn(false); setMockSensorAvailability(hasSensor: true); expectLater(service.hasSensor(), completion(true)); }); test('false - Android', () async { - when(() => localPlatform.isAndroid).thenReturn(true); + when(() => localPlatform.isIOS).thenReturn(false); setMockSensorAvailability(hasSensor: false); expectLater(service.hasSensor(), completion(false)); }); test('false - iOS', () async { - when(() => localPlatform.isAndroid).thenReturn(false); + when(() => localPlatform.isIOS).thenReturn(true); expectLater(service.hasSensor(), completion(false)); }); }, @@ -64,7 +64,7 @@ void main() { group('luxStream', () { test('Android', () async { - when(() => localPlatform.isAndroid).thenReturn(true); + when(() => localPlatform.isIOS).thenReturn(false); final stream = service.luxStream(); final List result = []; final subscription = stream.listen(result.add); @@ -77,7 +77,7 @@ void main() { }); test('iOS', () async { - when(() => localPlatform.isAndroid).thenReturn(false); + when(() => localPlatform.isIOS).thenReturn(true); expect(service.luxStream(), const Stream.empty()); }); }); diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 0000000..f0ac3fb --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + return GoldenToolkit.runWithConfiguration( + () async { + await loadAppFonts(); + await testMain(); + }, + config: GoldenToolkitConfiguration( + defaultDevices: _defaultDevices + + _defaultDevices + .map( + (d) => Device( + name: '${d.name} (Dark)', + size: d.size, + devicePixelRatio: d.devicePixelRatio, + safeArea: d.safeArea, + brightness: Brightness.dark, + ), + ) + .toList(), + ), + ); +} + +const _defaultDevices = [ + Device( + name: 'iPhone 8', + size: Size(375, 667), + devicePixelRatio: 2.0, + ), + Device( + name: 'iPhone 13 Pro', + size: Size(390, 844), + devicePixelRatio: 3.0, + safeArea: EdgeInsets.only(top: 44, bottom: 34), + ), + Device( + name: 'iPhone 15 Pro Max', + size: Size(430, 932), + devicePixelRatio: 3.0, + safeArea: EdgeInsets.only(top: 44, bottom: 34), + ), +]; diff --git a/test/screens/metering/goldens/metering_screen.png b/test/screens/metering/goldens/metering_screen.png new file mode 100644 index 0000000..283c7ca Binary files /dev/null and b/test/screens/metering/goldens/metering_screen.png differ diff --git a/test/screens/metering/screen_metering_golden_test.dart b/test/screens/metering/screen_metering_golden_test.dart new file mode 100644 index 0000000..d17cb43 --- /dev/null +++ b/test/screens/metering/screen_metering_golden_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:lightmeter/data/models/ev_source_type.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; +import 'package:lightmeter/screens/metering/flow_metering.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../integration_test/utils/platform_channel_mock.dart'; +import '../../application_mock.dart'; + +class _MeteringScreenConfig { + final IAPProductStatus iapProductStatus; + final EvSourceType evSourceType; + + _MeteringScreenConfig( + this.iapProductStatus, + this.evSourceType, + ); + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write(iapProductStatus.toString().split('.')[1]); + buffer.write(' - '); + buffer.write(evSourceType.toString().split('.')[1]); + return buffer.toString(); + } +} + +final _testScenarios = [IAPProductStatus.purchased, IAPProductStatus.purchasable].expand( + (iapProductStatus) => EvSourceType.values.map( + (evSourceType) => _MeteringScreenConfig(iapProductStatus, evSourceType), + ), +); + +void main() { + Future setEvSource(WidgetTester tester, Key scenarioWidgetKey, EvSourceType evSourceType) async { + final flow = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(MeteringFlow), + ); + final BuildContext context = tester.element(flow); + if (UserPreferencesProvider.evSourceTypeOf(context) != evSourceType) { + UserPreferencesProvider.of(context).toggleEvSourceType(); + } + await tester.pumpAndSettle(); + } + + Future setTheme(WidgetTester tester, Key scenarioWidgetKey, ThemeType themeType) async { + final flow = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(MeteringFlow), + ); + final BuildContext context = tester.element(flow); + UserPreferencesProvider.of(context).setThemeType(themeType); + await tester.pumpAndSettle(); + } + + Future takePhoto(WidgetTester tester, Key scenarioWidgetKey) async { + final button = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(MeteringMeasureButton), + ); + await tester.tap(button); + await tester.pump(const Duration(seconds: 2)); // wait for circular progress indicator + await tester.pump(const Duration(seconds: 1)); // wait for circular progress indicator + await tester.pumpAndSettle(); + } + + Future toggleIncidentMetering(WidgetTester tester, Key scenarioWidgetKey, double ev) async { + final button = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(MeteringMeasureButton), + ); + await tester.tap(button); + await sendMockIncidentEv(ev); + await tester.tap(button); + await tester.pumpAndSettle(); + } + + setUpAll(() { + SharedPreferences.setMockInitialValues({ + UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index, + UserPreferencesService.meteringScreenLayoutKey: json.encode( + { + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + }.toJson(), + ), + }); + }); + + testGoldens( + 'MeteringScreen golden test', + (tester) async { + final builder = DeviceBuilder(); + for (final scenario in _testScenarios) { + builder.addScenario( + name: scenario.toString(), + widget: _MockMeteringFlow(productStatus: scenario.iapProductStatus), + onCreate: (scenarioWidgetKey) async { + await setEvSource(tester, scenarioWidgetKey, scenario.evSourceType); + if (scenarioWidgetKey.toString().contains('Dark')) { + await setTheme(tester, scenarioWidgetKey, ThemeType.dark); + } + if (scenario.evSourceType == EvSourceType.camera) { + await takePhoto(tester, scenarioWidgetKey); + } else { + await toggleIncidentMetering(tester, scenarioWidgetKey, 7.3); + } + }, + ); + } + await tester.pumpDeviceBuilder(builder); + await screenMatchesGolden( + tester, + 'metering_screen', + ); + }, + ); +} + +class _MockMeteringFlow extends StatelessWidget { + final IAPProductStatus productStatus; + + const _MockMeteringFlow({required this.productStatus}); + + @override + Widget build(BuildContext context) { + return GoldenTestApplicationMock( + productStatus: productStatus, + child: const MeteringFlow(), + ); + } +} diff --git a/test/screens/settings/goldens/settings_screen.png b/test/screens/settings/goldens/settings_screen.png new file mode 100644 index 0000000..6b23c7c Binary files /dev/null and b/test/screens/settings/goldens/settings_screen.png differ diff --git a/test/screens/settings/settings_screen_golden_test.dart b/test/screens/settings/settings_screen_golden_test.dart new file mode 100644 index 0000000..c1deae5 --- /dev/null +++ b/test/screens/settings/settings_screen_golden_test.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:lightmeter/data/models/ev_source_type.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/screens/settings/flow_settings.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../application_mock.dart'; + +class _SettingsScreenConfig { + final IAPProductStatus iapProductStatus; + + _SettingsScreenConfig(this.iapProductStatus); + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write(iapProductStatus.toString().split('.')[1]); + return buffer.toString(); + } +} + +final _testScenarios = [IAPProductStatus.purchased, IAPProductStatus.purchasable].map( + (iapProductStatus) => _SettingsScreenConfig(iapProductStatus), +); + +void main() { + Future setTheme(WidgetTester tester, Key scenarioWidgetKey, ThemeType themeType) async { + final flow = find.descendant( + of: find.byKey(scenarioWidgetKey), + matching: find.byType(SettingsFlow), + ); + final BuildContext context = tester.element(flow); + UserPreferencesProvider.of(context).setThemeType(themeType); + await tester.pumpAndSettle(); + } + + setUpAll(() { + SharedPreferences.setMockInitialValues({ + UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index, + UserPreferencesService.meteringScreenLayoutKey: json.encode( + { + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + }.toJson(), + ), + }); + PackageInfo.setMockInitialValues( + appName: 'Lightmeter', + packageName: 'com.vodemn.lightmeter', + version: '0.18.0', + buildNumber: '48', + buildSignature: '', + ); + }); + + testGoldens( + 'SettingsScreen golden test', + (tester) async { + final builder = DeviceBuilder(); + for (final scenario in _testScenarios) { + builder.addScenario( + name: scenario.toString(), + widget: _MockSettingsFlow(productStatus: scenario.iapProductStatus), + onCreate: (scenarioWidgetKey) async { + if (scenarioWidgetKey.toString().contains('Dark')) { + await setTheme(tester, scenarioWidgetKey, ThemeType.dark); + } + }, + ); + } + await tester.pumpDeviceBuilder(builder); + await screenMatchesGolden( + tester, + 'settings_screen', + ); + }, + ); +} + +class _MockSettingsFlow extends StatelessWidget { + final IAPProductStatus productStatus; + + const _MockSettingsFlow({required this.productStatus}); + + @override + Widget build(BuildContext context) { + return GoldenTestApplicationMock( + productStatus: productStatus, + child: const SettingsFlow(), + ); + } +}