From 7f81547e23dc747e5aa9398da41ec935a5266044 Mon Sep 17 00:00:00 2001 From: ChauVanSang Date: Mon, 8 Dec 2025 21:56:37 +0700 Subject: [PATCH] Complete CI/CD for Github Action and Circle CI --- .circleci/config.yml | 123 ++++++++++++ .github/workflows/build-apk.yml | 42 ++-- .gitignore | 4 + app/build.gradle.kts | 24 +++ flutter_module/coverage/lcov.info | 187 ++++++++++++++++++ .../lib/core/services/hybrid_bus_service.dart | 41 ---- flutter_module/lib/main_mock.dart | 49 ----- flutter_module/pubspec.lock | 176 +++++++++++++++-- flutter_module/pubspec.yaml | 4 +- .../test/bloc/appliance_detail_bloc_test.dart | 139 +++++++++++++ .../appliance_detail_bloc_test.mocks.dart | 90 +++++++++ .../test/bloc/hybrid_bloc_test.dart | 128 ++++++++++++ .../test/bloc/hybrid_bloc_test.mocks.dart | 90 +++++++++ .../services/navigation_service_test.dart | 63 ++++++ 14 files changed, 1043 insertions(+), 117 deletions(-) create mode 100644 .circleci/config.yml create mode 100644 flutter_module/coverage/lcov.info delete mode 100644 flutter_module/lib/main_mock.dart create mode 100644 flutter_module/test/bloc/appliance_detail_bloc_test.dart create mode 100644 flutter_module/test/bloc/appliance_detail_bloc_test.mocks.dart create mode 100644 flutter_module/test/bloc/hybrid_bloc_test.dart create mode 100644 flutter_module/test/bloc/hybrid_bloc_test.mocks.dart create mode 100644 flutter_module/test/services/navigation_service_test.dart diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..f042c11 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,123 @@ +version: 2.1 + +orbs: + flutter: circleci/flutter@3.0.0 + + +executors: + android-flutter: + docker: + - image: cimg/android:2025.12 + working_directory: ~/project + environment: + JVM_OPTS: -Xmx3200m + GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2 + +jobs: + build: + executor: android-flutter + steps: + - checkout + + - run: + name: Extract and sanitize branch name + command: | + SANITIZED_BRANCH="${CIRCLE_BRANCH//\//-}" + echo "export SANITIZED_BRANCH=$SANITIZED_BRANCH" >> $BASH_ENV + echo "Sanitized branch: $SANITIZED_BRANCH" + + - restore_cache: + keys: + - gradle-cache-v1-{{ checksum "app/build.gradle.kts" }} + - gradle-cache-v1- + + - restore_cache: + keys: + - flutter-dart-tool-v1-{{ checksum "flutter_module/pubspec.lock" }} + - flutter-dart-tool-v1- + + - flutter/install_sdk_and_pub: + version: 3.35.6 + channel: stable + app_dir: flutter_module + + - run: + name: Accept Android licenses + command: yes | flutter doctor --android-licenses || true + + - run: + name: Run Flutter doctor + command: flutter doctor -v + + - run: + name: Run Flutter unit tests + working_directory: flutter_module + command: | + flutter pub get + flutter test --coverage --machine > test-results.json || true + flutter test --coverage + mkdir test-results + cp test-results.json test-results + + - store_test_results: + path: flutter_module/test-results + + - store_artifacts: + path: flutter_module/test-results/test-results.json + destination: test-results/flutter-test-results.json + + - run: + name: Decode Keystore + command: | + echo "$KEYSTORE_BASE64" | base64 -d > app/keystore.jks + + - run: + name: Create keystore.properties + command: | + echo "storePassword=$KEYSTORE_PASSWORD" > keystore.properties + echo "keyPassword=$KEY_PASSWORD" >> keystore.properties + echo "keyAlias=$KEY_ALIAS" >> keystore.properties + echo "storeFile=keystore.jks" >> keystore.properties + + - run: + name: Grant execute permission for gradlew + command: chmod +x gradlew + + - run: + name: Build Release APK + command: ./gradlew assembleRelease --stacktrace + + - save_cache: + key: gradle-cache-v1-{{ checksum "app/build.gradle.kts" }} + paths: + - ~/.gradle + - .gradle + + - save_cache: + key: flutter-dart-tool-v1-{{ checksum "flutter_module/pubspec.lock" }} + paths: + - flutter_module/.dart_tool + + - store_artifacts: + path: app/build/outputs/apk/release/app-release.apk + destination: app-release-${SANITIZED_BRANCH}-${CIRCLE_BUILD_NUM}.apk + + - run: + name: Rename APK for storage + command: | + mkdir -p ~/artifacts + cp app/build/outputs/apk/release/app-release.apk ~/artifacts/app-release-${SANITIZED_BRANCH}-${CIRCLE_BUILD_NUM}.apk + + - store_artifacts: + path: ~/artifacts + destination: apk + + - store_artifacts: + path: flutter_module/coverage + destination: coverage + +workflows: + build-and-deploy: + jobs: + - build: + context: android-build \ No newline at end of file diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 6976f71..b4fd0c1 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -20,7 +20,10 @@ jobs: - name: Extract branch name id: extract_branch shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT + run: | + BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + SANITIZED_BRANCH="${BRANCH_NAME//\//-}" + echo "branch=${SANITIZED_BRANCH}" >> $GITHUB_OUTPUT - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -32,33 +35,44 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.24.0' + flutter-version: '3.35.6' channel: 'stable' cache: true + - name: Accept Android licenses + run: yes | flutter doctor --android-licenses + + - name: Run Flutter doctor + run: flutter doctor -v + - name: Install Flutter dependencies working-directory: ./flutter_module run: flutter pub get + - name: Run Flutter unit tests + working-directory: ./flutter_module + run: flutter test --coverage + + - name: Decode Keystore + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/keystore.jks + + - name: Create keystore.properties + run: | + echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > keystore.properties + echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> keystore.properties + echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> keystore.properties + echo "storeFile=keystore.jks" >> keystore.properties + - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Build Debug APK - run: ./gradlew assembleDebug --stacktrace - - name: Build Release APK run: ./gradlew assembleRelease --stacktrace - - - name: Upload Debug APK - uses: actions/upload-artifact@v4 - with: - name: app-debug-${{ steps.extract_branch.outputs.branch }}-${{ github.run_number }} - path: app/build/outputs/apk/debug/app-debug.apk - retention-days: 30 - + - name: Upload Release APK uses: actions/upload-artifact@v4 with: name: app-release-${{ steps.extract_branch.outputs.branch }}-${{ github.run_number }} - path: app/build/outputs/apk/release/app-release-unsigned.apk + path: app/build/outputs/apk/release/app-release.apk retention-days: 30 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8127667..b6ebf39 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ google-services.json # Android Profiling *.hprof nul +app/keystore.properties +squash_from_commit.sh +log.sh +squash_commits.sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67b43c7..59ebca7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -6,6 +9,13 @@ plugins { id("kotlin-kapt") } +// Load keystore properties +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.example.myapplication" compileSdk { @@ -22,6 +32,17 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + storeFile = file(keystoreProperties.getProperty("storeFile") ?: "keystore.jks") + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { isMinifyEnabled = false @@ -29,6 +50,9 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } } } diff --git a/flutter_module/coverage/lcov.info b/flutter_module/coverage/lcov.info new file mode 100644 index 0000000..a12b112 --- /dev/null +++ b/flutter_module/coverage/lcov.info @@ -0,0 +1,187 @@ +SF:lib\core\services\hybrid_bus_service.dart +DA:29,0 +DA:31,0 +DA:34,0 +DA:39,0 +DA:40,0 +DA:43,0 +DA:48,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:58,0 +DA:60,0 +DA:64,0 +DA:66,0 +DA:67,0 +DA:68,0 +LF:17 +LH:0 +end_of_record +SF:lib\core\services\navigation_service.dart +DA:10,1 +DA:12,3 +DA:18,1 +DA:21,1 +LF:4 +LH:4 +end_of_record +SF:lib\domain\model\appliance.dart +DA:13,0 +DA:14,0 +DA:15,0 +DA:17,0 +DA:19,0 +DA:21,0 +DA:23,0 +DA:31,0 +DA:33,0 +DA:35,0 +DA:37,0 +DA:39,0 +DA:41,0 +DA:43,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:59,0 +DA:61,0 +DA:63,0 +DA:71,0 +DA:73,0 +DA:75,0 +DA:77,0 +DA:79,0 +DA:85,0 +DA:87,0 +DA:89,0 +DA:91,0 +DA:93,0 +DA:108,1 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:128,0 +DA:129,0 +DA:134,0 +DA:135,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:145,0 +DA:146,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:170,0 +DA:174,0 +DA:175,0 +DA:180,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:195,0 +DA:196,0 +DA:199,0 +DA:200,0 +DA:205,1 +DA:206,7 +LF:82 +LH:3 +end_of_record +SF:lib\domain\model\control_data.dart +DA:7,0 +DA:9,0 +DA:11,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:20,0 +DA:35,1 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:55,0 +DA:56,0 +DA:59,1 +DA:60,4 +LF:20 +LH:3 +end_of_record +SF:lib\ui\appliance\bloc\appliance_detail_bloc.dart +DA:14,1 +DA:17,0 +DA:18,0 +DA:19,1 +DA:20,2 +DA:21,1 +DA:22,1 +DA:23,2 +DA:25,1 +DA:26,2 +DA:27,3 +LF:11 +LH:9 +end_of_record +SF:lib\core\di\injection_container.dart +DA:5,0 +DA:7,0 +DA:8,0 +DA:10,0 +LF:4 +LH:0 +end_of_record +SF:lib\core\theme\app.dart +DA:4,1 +LF:1 +LH:1 +end_of_record +SF:lib\ui\home\bloc\hybrid_bloc.dart +DA:12,1 +DA:13,1 +DA:14,2 +DA:16,3 +DA:18,1 +DA:19,0 +DA:20,3 +DA:22,3 +DA:27,2 +DA:28,1 +DA:29,1 +DA:30,3 +DA:32,1 +DA:33,2 +DA:35,1 +DA:36,2 +LF:16 +LH:15 +end_of_record diff --git a/flutter_module/lib/core/services/hybrid_bus_service.dart b/flutter_module/lib/core/services/hybrid_bus_service.dart index 1d222f7..e5bec48 100644 --- a/flutter_module/lib/core/services/hybrid_bus_service.dart +++ b/flutter_module/lib/core/services/hybrid_bus_service.dart @@ -2,8 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_module/domain/model/appliance.dart'; -import 'package:flutter_module/domain/model/control_data.dart'; abstract class HybridBusService { Future sendEventToNative(String event, Map data); @@ -71,42 +69,3 @@ class HybridBusServiceImpl implements HybridBusService { } } -class MockHybridBusService implements HybridBusService { - @override - Future sendEventToNative( - String event, - Map data, - ) async {} - - @override - void startListening([Map? args]) {} - - @override - void registerListener( - void Function(String event, Map data) onEvent, - ) async { - await Future.delayed(const Duration(seconds: 2)); - - final appliance = Appliance( - id: '1', - name: 'Appliance 1', - type: ApplianceType.fridge, - condition: ApplianceCondition.good, - description: 'Description 1', - controls: [ - Control( - name: "fridge COMPARTMENT", - mode: ControlMode.fridge, - degree: 1, - startDegree: 10, - endDegree: 20, - ), - ], - ); - - onEvent('appliance', {'appliance': appliance.toJson()}); - } - - @override - void stopListening() {} -} diff --git a/flutter_module/lib/main_mock.dart b/flutter_module/lib/main_mock.dart deleted file mode 100644 index f050014..0000000 --- a/flutter_module/lib/main_mock.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_module/core/di/injection_container.dart'; -import 'package:flutter_module/core/theme/app.dart'; -import 'package:flutter_module/core/theme/app_theme.dart'; - -import 'package:flutter_module/ui/home/bloc/hybrid_bloc.dart'; -import 'package:flutter_module/ui/home/home_page.dart'; -import 'core/services/hybrid_bus_service.dart'; -import 'core/services/navigation_service.dart'; -import 'core/routes/route_mapper.dart'; - -void setupMockLocator() { - getIt.registerLazySingleton(() => MockHybridBusService()); - getIt.registerLazySingleton(() => NavigationServiceImpl()); -} - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - setupMockLocator(); - runApp(ApplianceApp()); -} - -class ApplianceApp extends StatelessWidget { - const ApplianceApp({super.key}); - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => - HybridBloc(getIt(), getIt()), - ), - ], - - child: MaterialApp( - title: 'Flutter module', - initialRoute: - WidgetsBinding.instance.platformDispatcher.defaultRouteName, - navigatorKey: App.navigatorKey, - theme: AppTheme.darkTheme(), - color: Theme.of(context).colorScheme.primary, - onGenerateRoute: RouteMapper.onGenerateRoute, - home: HomePage(), - ), - ); - } -} diff --git a/flutter_module/pubspec.lock b/flutter_module/pubspec.lock index 672aadf..51251ee 100644 --- a/flutter_module/pubspec.lock +++ b/flutter_module/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "85.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "7.7.1" archive: dependency: transitive description: @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.4" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -61,10 +69,10 @@ packages: dependency: transitive description: name: build - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "3.1.0" build_config: dependency: transitive description: @@ -81,14 +89,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46 + url: "https://pub.dev" + source: hosted + version: "3.0.3" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" + sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.7.1" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b" + url: "https://pub.dev" + source: hosted + version: "9.3.1" built_collection: dependency: transitive description: @@ -121,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -161,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: transitive description: @@ -181,10 +221,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.1" dartx: dependency: transitive description: @@ -193,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" dio: dependency: "direct main" description: @@ -323,6 +371,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" get_it: dependency: "direct main" description: @@ -403,6 +459,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -483,6 +547,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + url: "https://pub.dev" + source: hosted + version: "5.5.0" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" nested: dependency: transitive description: @@ -491,6 +571,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -579,6 +667,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -596,10 +700,26 @@ packages: dependency: transitive description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "3.1.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -648,6 +768,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" test_api: dependency: transitive description: @@ -656,6 +784,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" time: dependency: transitive description: @@ -664,6 +800,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.5" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -744,6 +888,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" xml: dependency: transitive description: diff --git a/flutter_module/pubspec.yaml b/flutter_module/pubspec.yaml index 08448ad..5796f2c 100644 --- a/flutter_module/pubspec.yaml +++ b/flutter_module/pubspec.yaml @@ -38,10 +38,12 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 - build_runner: ^2.10.0 + build_runner: ^2.4.13 freezed: ^3.2.3 flutter_gen_runner: ^5.12.0 flutter_gen: ^5.12.0 + bloc_test: ^9.1.7 + mockito: ^5.4.4 flutter_gen: output: lib/gen/ diff --git a/flutter_module/test/bloc/appliance_detail_bloc_test.dart b/flutter_module/test/bloc/appliance_detail_bloc_test.dart new file mode 100644 index 0000000..9b80143 --- /dev/null +++ b/flutter_module/test/bloc/appliance_detail_bloc_test.dart @@ -0,0 +1,139 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_module/core/services/hybrid_bus_service.dart'; +import 'package:flutter_module/core/services/navigation_service.dart'; +import 'package:flutter_module/domain/model/appliance.dart'; +import 'package:flutter_module/domain/model/control_data.dart'; +import 'package:flutter_module/ui/appliance/bloc/appliance_detail_bloc.dart'; +import 'package:flutter_module/ui/appliance/bloc/appliance_detail_event.dart'; +import 'package:flutter_module/ui/appliance/bloc/appliance_detail_state.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'appliance_detail_bloc_test.mocks.dart'; + +@GenerateMocks([HybridBusService, NavigationService]) + +void main() { + late MockHybridBusService mockHybridBusService; + late MockNavigationService mockNavigationService; + + final testAppliance = Appliance( + id: '1', + name: 'Test Fridge', + description: 'A test refrigerator', + type: ApplianceType.fridge, + condition: ApplianceCondition.good, + controls: [ + Control( + name: 'Temperature', + mode: ControlMode.fridge, + degree: 5, + startDegree: 0, + endDegree: 10, + ), + ], + ); + + setUp(() { + mockHybridBusService = MockHybridBusService(); + mockNavigationService = MockNavigationService(); + }); + + group('ApplianceDetailBloc', () { + test('initial state should be ApplianceDetailState.initial', () { + final bloc = ApplianceDetailBloc( + hybridBusService: mockHybridBusService, + navigationService: mockNavigationService, + ); + + expect(bloc.state, const ApplianceDetailState.initial()); + + bloc.close(); + }); + + blocTest( + 'should emit loadSuccess when started event is added', + build: () => ApplianceDetailBloc( + hybridBusService: mockHybridBusService, + navigationService: mockNavigationService, + ), + act: (bloc) => bloc.add(ApplianceDetailEvent.started(testAppliance)), + expect: () => [ + ApplianceDetailState.loadSuccess(testAppliance), + ], + ); + + blocTest( + 'should send navigateBack event to native when navigateBack is added', + build: () { + when(mockHybridBusService.sendEventToNative(any, any)) + .thenAnswer((_) async {}); + return ApplianceDetailBloc( + hybridBusService: mockHybridBusService, + navigationService: mockNavigationService, + ); + }, + act: (bloc) => bloc.add(const ApplianceDetailEvent.navigateBack()), + verify: (_) { + verify( + mockHybridBusService.sendEventToNative('navigateBack', {}), + ).called(1); + }, + ); + + test('should load appliance data correctly', () async { + final bloc = ApplianceDetailBloc( + hybridBusService: mockHybridBusService, + navigationService: mockNavigationService, + ); + + bloc.add(ApplianceDetailEvent.started(testAppliance)); + await Future.delayed(const Duration(milliseconds: 100)); + + bloc.state.when( + initial: () => fail('State should not be initial'), + loadSuccess: (appliance) { + expect(appliance.id, testAppliance.id); + expect(appliance.name, testAppliance.name); + expect(appliance.type, ApplianceType.fridge); + expect(appliance.condition, ApplianceCondition.good); + expect(appliance.controls.length, 1); + }, + ); + + bloc.close(); + }); + + blocTest( + 'should handle multiple appliance loads', + build: () => ApplianceDetailBloc( + hybridBusService: mockHybridBusService, + navigationService: mockNavigationService, + ), + act: (bloc) { + bloc.add(ApplianceDetailEvent.started(testAppliance)); + + final secondAppliance = Appliance( + id: '2', + name: 'Test TV', + description: 'A test television', + type: ApplianceType.tv, + condition: ApplianceCondition.newCondition, + controls: [], + ); + + bloc.add(ApplianceDetailEvent.started(secondAppliance)); + }, + expect: () => [ + ApplianceDetailState.loadSuccess(testAppliance), + predicate((state) { + return state.maybeWhen( + loadSuccess: (appliance) => appliance.id == '2' && appliance.name == 'Test TV', + orElse: () => false, + ); + }), + ], + ); + }); +} diff --git a/flutter_module/test/bloc/appliance_detail_bloc_test.mocks.dart b/flutter_module/test/bloc/appliance_detail_bloc_test.mocks.dart new file mode 100644 index 0000000..c9ef41f --- /dev/null +++ b/flutter_module/test/bloc/appliance_detail_bloc_test.mocks.dart @@ -0,0 +1,90 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in flutter_module/test/bloc/appliance_detail_bloc_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:flutter_module/core/services/hybrid_bus_service.dart' as _i2; +import 'package:flutter_module/core/services/navigation_service.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [HybridBusService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHybridBusService extends _i1.Mock implements _i2.HybridBusService { + MockHybridBusService() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future sendEventToNative( + String? event, + Map? data, + ) => + (super.noSuchMethod( + Invocation.method(#sendEventToNative, [event, data]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + void startListening([Map? args]) => super.noSuchMethod( + Invocation.method(#startListening, [args]), + returnValueForMissingStub: null, + ); + + @override + void registerListener(void Function(String, Map)? onEvent) => + super.noSuchMethod( + Invocation.method(#registerListener, [onEvent]), + returnValueForMissingStub: null, + ); + + @override + void stopListening() => super.noSuchMethod( + Invocation.method(#stopListening, []), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [NavigationService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockNavigationService extends _i1.Mock implements _i4.NavigationService { + MockNavigationService() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future? navigateTo(String? routeName, {Object? arguments}) => + (super.noSuchMethod( + Invocation.method( + #navigateTo, + [routeName], + {#arguments: arguments}, + ), + ) + as _i3.Future?); + + @override + void goBack() => super.noSuchMethod( + Invocation.method(#goBack, []), + returnValueForMissingStub: null, + ); +} diff --git a/flutter_module/test/bloc/hybrid_bloc_test.dart b/flutter_module/test/bloc/hybrid_bloc_test.dart new file mode 100644 index 0000000..ff087cc --- /dev/null +++ b/flutter_module/test/bloc/hybrid_bloc_test.dart @@ -0,0 +1,128 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_module/core/services/hybrid_bus_service.dart'; +import 'package:flutter_module/core/services/navigation_service.dart'; +import 'package:flutter_module/ui/home/bloc/hybrid_bloc.dart'; +import 'package:flutter_module/ui/home/bloc/hybrid_event.dart'; +import 'package:flutter_module/ui/home/bloc/hybrid_state.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'hybrid_bloc_test.mocks.dart'; + +@GenerateMocks([HybridBusService, NavigationService]) +void main() { + late MockHybridBusService mockHybridBusService; + late MockNavigationService mockNavigationService; + + setUp(() { + mockHybridBusService = MockHybridBusService(); + mockNavigationService = MockNavigationService(); + + when(mockHybridBusService.registerListener(any)).thenReturn(null); + }); + + group('HybridBloc', () { + test('initial state should be HybridState', () { + final bloc = HybridBloc(mockHybridBusService, mockNavigationService); + + + expect(bloc.state, const HybridState()); + + bloc.close(); + }); + + test('should register listener on initialization', () { + + final bloc = HybridBloc(mockHybridBusService, mockNavigationService); + + + verify(mockHybridBusService.registerListener(any)).called(1); + + bloc.close(); + }); + + blocTest( + 'should send event to native when navigateBackNative is added', + build: () { + when(mockHybridBusService.sendEventToNative(any, any)) + .thenAnswer((_) async {}); + return HybridBloc(mockHybridBusService, mockNavigationService); + }, + act: (bloc) => bloc.add(const HybridEvent.navigateBackNative()), + verify: (_) { + verify( + mockHybridBusService.sendEventToNative('navigateBack', {}), + ).called(1); + }, + ); + + blocTest( + 'should navigate to route when navigateRoute is added', + build: () { + when(mockNavigationService.navigateTo(any, arguments: anyNamed('arguments'))) + .thenReturn(null); + return HybridBloc(mockHybridBusService, mockNavigationService); + }, + act: (bloc) => bloc.add( + const HybridEvent.navigateRoute('/test-route', {'arg': 'value'}), + ), + verify: (_) { + verify( + mockNavigationService.navigateTo( + '/test-route', + arguments: {'arg': 'value'}, + ), + ).called(1); + }, + ); + + blocTest( + 'should send custom event to native when sendEventToNative is added', + build: () { + when(mockHybridBusService.sendEventToNative(any, any)) + .thenAnswer((_) async {}); + return HybridBloc(mockHybridBusService, mockNavigationService); + }, + act: (bloc) => bloc.add( + const HybridEvent.sendEventToNative('customEvent', {'data': 'test'}), + ), + verify: (_) { + verify( + mockHybridBusService.sendEventToNative( + 'customEvent', + {'data': 'test'}, + ), + ).called(1); + }, + ); + + test('should handle navigate event from native', () async { + + late void Function(String event, Map data) listener; + + when(mockHybridBusService.registerListener(any)).thenAnswer((invocation) { + listener = invocation.positionalArguments[0]; + }); + + when(mockNavigationService.navigateTo(any, arguments: anyNamed('arguments'))) + .thenReturn(null); + + final bloc = HybridBloc(mockHybridBusService, mockNavigationService); + + listener('navigate', {'routeName': '/appliance', 'id': '123'}); + + await Future.delayed(const Duration(milliseconds: 100)); + + + verify( + mockNavigationService.navigateTo( + '/appliance', + arguments: {'routeName': '/appliance', 'id': '123'}, + ), + ).called(1); + + bloc.close(); + }); + }); +} diff --git a/flutter_module/test/bloc/hybrid_bloc_test.mocks.dart b/flutter_module/test/bloc/hybrid_bloc_test.mocks.dart new file mode 100644 index 0000000..a3d67df --- /dev/null +++ b/flutter_module/test/bloc/hybrid_bloc_test.mocks.dart @@ -0,0 +1,90 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in flutter_module/test/bloc/hybrid_bloc_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:flutter_module/core/services/hybrid_bus_service.dart' as _i2; +import 'package:flutter_module/core/services/navigation_service.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [HybridBusService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHybridBusService extends _i1.Mock implements _i2.HybridBusService { + MockHybridBusService() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future sendEventToNative( + String? event, + Map? data, + ) => + (super.noSuchMethod( + Invocation.method(#sendEventToNative, [event, data]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + void startListening([Map? args]) => super.noSuchMethod( + Invocation.method(#startListening, [args]), + returnValueForMissingStub: null, + ); + + @override + void registerListener(void Function(String, Map)? onEvent) => + super.noSuchMethod( + Invocation.method(#registerListener, [onEvent]), + returnValueForMissingStub: null, + ); + + @override + void stopListening() => super.noSuchMethod( + Invocation.method(#stopListening, []), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [NavigationService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockNavigationService extends _i1.Mock implements _i4.NavigationService { + MockNavigationService() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future? navigateTo(String? routeName, {Object? arguments}) => + (super.noSuchMethod( + Invocation.method( + #navigateTo, + [routeName], + {#arguments: arguments}, + ), + ) + as _i3.Future?); + + @override + void goBack() => super.noSuchMethod( + Invocation.method(#goBack, []), + returnValueForMissingStub: null, + ); +} diff --git a/flutter_module/test/services/navigation_service_test.dart b/flutter_module/test/services/navigation_service_test.dart new file mode 100644 index 0000000..c3ffb1a --- /dev/null +++ b/flutter_module/test/services/navigation_service_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_module/core/services/navigation_service.dart'; +import 'package:flutter_module/core/theme/app.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('NavigationService', () { + late NavigationService navigationService; + + setUp(() { + navigationService = NavigationServiceImpl(); + }); + + testWidgets('navigateTo should call pushNamed with correct route name', + (WidgetTester tester) async { + // Arrange + const routeName = '/test-route'; + const arguments = {'key': 'value'}; + + // Create a mock navigator state + final navigatorKey = GlobalKey(); + App.navigatorKey = navigatorKey; + + // Build a test app with routes + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Text('Home')), + routes: { + routeName: (context) => const Scaffold(body: Text('Test Route')), + }, + ), + ); + + // Act + navigationService.navigateTo(routeName, arguments: arguments); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('Test Route'), findsOneWidget); + }); + + test('navigateTo returns null when navigator is not available', () { + // Arrange + App.navigatorKey = GlobalKey(); + + // Act + final result = navigationService.navigateTo('/test'); + + // Assert + expect(result, isNull); + }); + + test('goBack should call SystemNavigator.pop', () { + // This test verifies the method can be called without errors + // Note: SystemNavigator.pop() is difficult to test directly + // as it requires platform channel mocking + + // Act & Assert - should not throw + expect(() => navigationService.goBack(), returnsNormally); + }); + }); +}