package com.servicebook import android.app.Activity import android.app.Instrumentation import android.content.Intent import android.net.Uri import android.util.Log import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasStateDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeLeft import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.servicebook.data.model.Vehicle import com.servicebook.data.preferences.DebugBillingOverrideMode import com.servicebook.ui.TestTags import com.servicebook.util.DateFormatter import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.File import java.util.UUID /** * Smoke tests for Service Book. * * These tests run on a real Android device (or GMD) and cover the primary user journeys * including filesystem access (SAF), camera integration, and the paywall. */ @RunWith(AndroidJUnit4::class) class SmokeTest { @get:Rule val composeTestRule = createAndroidComposeRule() @Before fun setUp() { val context = InstrumentationRegistry.getInstrumentation().targetContext val app = context.applicationContext as ServiceBookApplication // Clear vault for each test to ensure isolation runBlocking { app.vaultStorage.clearVault() app.preferences.setVaultUri(null) } Intents.init() } @After fun tearDown() { val app = composeTestRule.activity.application as ServiceBookApplication runBlocking { app.vaultStorage.clearVault() app.preferences.setVaultUri(null) // Reset billing override to NONE to prevent leakage app.debugBillingOverride?.setMode(DebugBillingOverrideMode.NONE) } Intents.clear() } private fun step(name: String, description: String) { Log.d("SmokeTest", "STEP: $name - $description") } private fun configureVaultAndNavigateToGarage() { val activity = composeTestRule.activity val app = activity.application as ServiceBookApplication // Skip the system picker by setting vault URI directly val vaultFile = File(activity.filesDir, "test_vault") if (!vaultFile.exists()) vaultFile.mkdirs() val uri = Uri.fromFile(vaultFile) runBlocking { app.preferences.setVaultUri(uri.toString()) app.applyVaultUri(uri) } composeTestRule.waitForIdle() } private fun navigateToAddVehicle() { composeTestRule.onNodeWithTag(TestTags.ADD_VEHICLE_FAB).performClick() composeTestRule.waitForIdle() } private fun navigateToSettings() { composeTestRule.onNodeWithTag(TestTags.SETTINGS_BUTTON).performClick() composeTestRule.waitForIdle() } private fun assertScreenTitle(titleResId: Int) { val expectedTitle = composeTestRule.activity.getString(titleResId) composeTestRule.onNodeWithText(expectedTitle).assertIsDisplayed() } private fun awaitTagVisible(tag: String, timeoutMillis: Long = 5000) { composeTestRule.waitUntil(timeoutMillis) { composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() } } private fun awaitTagGone(tag: String, timeoutMillis: Long = 5000) { composeTestRule.waitUntil(timeoutMillis) { composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty() } } private fun awaitText(text: String, timeoutMillis: Long = 5000) { composeTestRule.waitUntil(timeoutMillis) { composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() } } private fun setBillingOverride(mode: DebugBillingOverrideMode) { step("setBillingOverride", "setting mode to $mode") val app = composeTestRule.activity.application as ServiceBookApplication runBlocking { app.debugBillingOverride?.setMode(mode) } composeTestRule.waitForIdle() } /** * Seeds a vehicle directly via repository to bypass the "Add Vehicle" UI gate. */ private fun addVehicleForTesting(nickname: String) { step("addVehicleForTesting", "adding $nickname") val app = composeTestRule.activity.application as ServiceBookApplication val vehicle = Vehicle( id = UUID.randomUUID().toString(), nickname = nickname, createdAt = DateFormatter.nowIso(), ) runBlocking { app.vehicleRepository.saveVehicle(vehicle) } composeTestRule.waitForIdle() } @Test fun s01_firstLaunch_showsWelcome() { step("S01", "verifying welcome screen") composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.setup_title)).assertIsDisplayed() composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.setup_choose_folder)).assertIsDisplayed() } @Test fun s03_addVehicle_savesCorrectly() { configureVaultAndNavigateToGarage() step("S03", "adding new vehicle") navigateToAddVehicle() composeTestRule.onNodeWithTag(TestTags.VEHICLE_NICKNAME_FIELD).performTextInput("Test Car") composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick() step("S03", "verifying vehicle in list") awaitText("Test Car") composeTestRule.onNodeWithText("Test Car").assertIsDisplayed() } @Test fun s05_addRecord_savesCorrectly() { configureVaultAndNavigateToGarage() addVehicleForTesting("Record Car") step("S05", "navigating to vehicle detail") composeTestRule.onNodeWithText("Record Car").performClick() awaitTagVisible(TestTags.ADD_RECORD_FAB) step("S05", "adding new record") composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB).performClick() awaitTagVisible(TestTags.RECORD_ODOMETER_FIELD) composeTestRule.onNodeWithTag(TestTags.RECORD_ODOMETER_FIELD).performTextInput("12345") composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick() step("S05", "verifying record in list") awaitText("123,45 km") // Assuming locale uses comma composeTestRule.onNodeWithText("123,45 km", substring = true).assertIsDisplayed() } @Test fun s07_addAttachment_showsThumbnail() { configureVaultAndNavigateToGarage() addVehicleForTesting("Attachment Car") step("S07", "navigating to add record") composeTestRule.onNodeWithText("Attachment Car").performClick() composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB).performClick() step("S07", "stubbing image picker intent") val resultData = Intent().apply { data = Uri.parse("content://test/image.jpg") } val result = Instrumentation.ActivityResult(Activity.RESULT_OK, resultData) intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(result) step("S07", "adding attachment") composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.attachment_add)).performClick() composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.attachment_pick_file)).performClick() step("S07", "verifying thumbnail") awaitTagVisible(TestTags.attachmentThumbnail("image.jpg")) composeTestRule.onNodeWithTag(TestTags.attachmentThumbnail("image.jpg")).assertIsDisplayed() } @Test fun s11_navigateToSettings_works() { configureVaultAndNavigateToGarage() step("S11", "navigating to settings") navigateToSettings() assertScreenTitle(R.string.settings_title) composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.settings_odometer_unit)).assertIsDisplayed() } @Test fun s13_editVehicle_updatesList() { configureVaultAndNavigateToGarage() addVehicleForTesting("Old Name") step("S13", "editing vehicle") composeTestRule.onNodeWithText("Old Name").performClick() composeTestRule.onNodeWithContentDescription(composeTestRule.activity.getString(R.string.action_edit)).performClick() composeTestRule.onNodeWithTag(TestTags.VEHICLE_NICKNAME_FIELD).performTextReplacement("New Name") composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick() step("S13", "verifying update") awaitText("New Name") composeTestRule.onNodeWithText("New Name").assertIsDisplayed() } @Test fun s16_deleteVehicle_returnsToGarage() { configureVaultAndNavigateToGarage() addVehicleForTesting("To Delete") step("S16", "deleting vehicle") composeTestRule.onNodeWithText("To Delete").performClick() composeTestRule.onNodeWithContentDescription(composeTestRule.activity.getString(R.string.action_edit)).performClick() composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.action_delete)).performClick() composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.action_confirm)).performClick() step("S16", "verifying empty garage") assertScreenTitle(R.string.my_garage) composeTestRule.onNodeWithText("To Delete").assertDoesNotExist() } @Test fun s17_backNavigation_works() { configureVaultAndNavigateToGarage() addVehicleForTesting("Nav Car") step("S17", "navigating deep") composeTestRule.onNodeWithText("Nav Car").performClick() assertScreenTitle(R.string.record_details_title) // Title changes based on screen step("S17", "going back") pressBack() assertScreenTitle(R.string.my_garage) } private fun pressBack() { InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_BACK) composeTestRule.waitForIdle() } @Test fun upgradeDialogOnFreeTier() { val activity = composeTestRule.activity step("S18", "configuring vault and seeding 1 vehicle directly") configureVaultAndNavigateToGarage() addVehicleForTesting("S18 Car") step("S18", "forcing free tier and attempting to add vehicle") setBillingOverride(DebugBillingOverrideMode.FORCE_FREE) navigateToAddVehicle() step("S18", "verifying paywall sheet is displayed") awaitTagVisible(TestTags.PAYWALL_INFO_SHEET) composeTestRule.onNodeWithText(activity.getString(R.string.paywall_sheet_headline)).assertIsDisplayed() step("S18", "dismissing sheet and verifying return to list") composeTestRule.onNodeWithText(activity.getString(R.string.paywall_sheet_dismiss)).performClick() awaitTagGone(TestTags.PAYWALL_INFO_SHEET) assertScreenTitle(R.string.my_garage) composeTestRule.onNodeWithText("S18 Car").assertIsDisplayed() } @Test fun readOnlyBannerAcrossScreens() { val activity = composeTestRule.activity step("S19", "configuring vault and seeding 2 vehicles directly") configureVaultAndNavigateToGarage() addVehicleForTesting("Car A") addVehicleForTesting("Car B") step("S19", "forcing free tier and verifying banner on VehicleList") setBillingOverride(DebugBillingOverrideMode.FORCE_FREE) awaitTagVisible(TestTags.PAYWALL_STATUS_CARD) composeTestRule.onNodeWithText(activity.getString(R.string.paywall_card_learn_more)).assertIsDisplayed() step("S19", "verifying swipe-to-dismiss is disabled for Car A") awaitText("Car A") composeTestRule.onNodeWithText("Car A").performTouchInput { swipeLeft() } composeTestRule.onNodeWithText("Car A").assertIsDisplayed() step("S19", "navigating to VehicleDetail and verifying FAB is disabled") composeTestRule.onNodeWithText("Car A").performClick() composeTestRule.waitForIdle() composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB) .assertIsDisplayed() .assert(hasStateDescription(activity.getString(R.string.disabled_upgrade_description))) step("S19", "navigating to Settings and verifying status") pressBack() // Return to VehicleList assertScreenTitle(R.string.my_garage) navigateToSettings() // SettingsScreen doesn't show the StatusCard but has a Premium section composeTestRule.onNodeWithText(activity.getString(R.string.settings_premium_title)).performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithText(activity.getString(R.string.upgrade_cta_title)).assertIsDisplayed() } @Test fun readOnlyLiftsOnPremium() { val activity = composeTestRule.activity step("S20", "configuring vault and seeding 2 vehicles directly") configureVaultAndNavigateToGarage() addVehicleForTesting("Car A") addVehicleForTesting("Car B") step("S20", "forcing free tier and verifying banner appears") setBillingOverride(DebugBillingOverrideMode.FORCE_FREE) awaitTagVisible(TestTags.PAYWALL_STATUS_CARD) step("S20", "switching to premium and waiting for banner to lift") setBillingOverride(DebugBillingOverrideMode.FORCE_PREMIUM) awaitTagGone(TestTags.PAYWALL_STATUS_CARD) step("S20", "verifying FAB now navigates to AddVehicle") awaitTagVisible(TestTags.ADD_VEHICLE_FAB) navigateToAddVehicle() composeTestRule.onNodeWithText(activity.getString(R.string.vehicle_add)).assertIsDisplayed() step("S20", "verifying banner is absent and FAB is enabled on detail") pressBack() awaitText("Car A") composeTestRule.onNodeWithText("Car A").performClick() composeTestRule.waitForIdle() awaitTagGone(TestTags.PAYWALL_STATUS_CARD) composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB) .assertIsDisplayed() .assert(hasClickAction()) } @Test fun s21_crashReportingOptInPersists() { configureVaultAndNavigateToGarage() step("S21", "navigating to settings") navigateToSettings() step("S21", "toggling crash reporting ON") composeTestRule.onNodeWithTag(TestTags.SETTINGS_CRASH_REPORTING_TOGGLE) .performScrollTo() .performClick() step("S21", "recreating activity and verifying toggle is still ON") composeTestRule.activityRule.scenario.recreate() navigateToSettings() composeTestRule.onNodeWithTag(TestTags.SETTINGS_CRASH_REPORTING_TOGGLE) .performScrollTo() .assert(hasStateDescription("Checked")) step("S21", "toggling crash reporting OFF") composeTestRule.onNodeWithTag(TestTags.SETTINGS_CRASH_REPORTING_TOGGLE) .performClick() step("S21", "recreating activity and verifying toggle is still OFF") composeTestRule.activityRule.scenario.recreate() navigateToSettings() composeTestRule.onNodeWithTag(TestTags.SETTINGS_CRASH_REPORTING_TOGGLE) .performScrollTo() .assert(hasStateDescription("Unchecked")) } }