From 66fb6adc65a9661fb68fe349d78872c8013dfef7 Mon Sep 17 00:00:00 2001 From: manjee Date: Thu, 12 Feb 2026 12:28:22 +0900 Subject: [PATCH] feat(batchtest): Add batch deep link testing with scenario management Add batch intent launcher feature enabling sequential deep link testing with scenario management, variable substitution, and result reporting. New domain models: TestScenario, DeepLinkTemplate, LaunchResult, BatchTestResult. New screen with scenario editor, batch execution, and aggregated result display with JSON export/import support. Closes #13 --- .../jvmMain/kotlin/com/manjee/linkops/App.kt | 20 + .../data/mapper/ParameterSubstituter.kt | 74 ++ .../linkops/data/mapper/ScenarioMapper.kt | 90 ++ .../data/parser/AmStartOutputParser.kt | 102 ++ .../repository/BatchTestRepositoryImpl.kt | 68 ++ .../com/manjee/linkops/di/AppContainer.kt | 42 + .../linkops/domain/model/LaunchResult.kt | 58 ++ .../linkops/domain/model/TestScenario.kt | 55 + .../domain/repository/BatchTestRepository.kt | 35 + .../batchtest/ExecuteBatchTestUseCase.kt | 44 + .../batchtest/ExportScenarioUseCase.kt | 21 + .../batchtest/ImportScenarioUseCase.kt | 21 + .../batchtest/ResolveTemplateUrisUseCase.kt | 41 + .../manjee/linkops/ui/navigation/NavGraph.kt | 1 + .../manjee/linkops/ui/navigation/Screen.kt | 6 + .../ui/screen/batchtest/BatchTestScreen.kt | 963 ++++++++++++++++++ .../ui/screen/batchtest/BatchTestViewModel.kt | 313 ++++++ .../data/mapper/ParameterSubstituterTest.kt | 190 ++++ .../linkops/data/mapper/ScenarioMapperTest.kt | 116 +++ .../data/parser/AmStartOutputParserTest.kt | 173 ++++ .../linkops/domain/model/TestScenarioTest.kt | 132 +++ .../batchtest/ExecuteBatchTestUseCaseTest.kt | 90 ++ .../ExportImportScenarioUseCaseTest.kt | 61 ++ .../batchtest/FakeBatchTestRepository.kt | 38 + 24 files changed, 2754 insertions(+) create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituter.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ScenarioMapper.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/AmStartOutputParser.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/BatchTestRepositoryImpl.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/LaunchResult.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/TestScenario.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/BatchTestRepository.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCase.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportScenarioUseCase.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ImportScenarioUseCase.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ResolveTemplateUrisUseCase.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestViewModel.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituterTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ScenarioMapperTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/AmStartOutputParserTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/model/TestScenarioTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCaseTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportImportScenarioUseCaseTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/FakeBatchTestRepository.kt diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt index 235b892..95e3950 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Terminal @@ -26,6 +27,8 @@ import com.manjee.linkops.ui.screen.logstream.LogStreamScreen import com.manjee.linkops.ui.screen.logstream.LogStreamViewModel import com.manjee.linkops.ui.screen.main.MainScreen import com.manjee.linkops.ui.screen.main.MainViewModel +import com.manjee.linkops.ui.screen.batchtest.BatchTestScreen +import com.manjee.linkops.ui.screen.batchtest.BatchTestViewModel import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerScreen import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerViewModel import com.manjee.linkops.ui.theme.LinkOpsTheme @@ -56,6 +59,7 @@ fun App() { val manifestAnalyzerViewModel = remember { ManifestAnalyzerViewModel() } val verificationDeepDiveViewModel = remember { VerificationDeepDiveViewModel() } val logStreamViewModel = remember { LogStreamViewModel() } + val batchTestViewModel = remember { BatchTestViewModel() } val keyboardShortcutHandler = remember { KeyboardShortcutHandler() } val searchFocusTrigger = remember { mutableStateOf(0) } @@ -69,6 +73,7 @@ fun App() { manifestAnalyzerViewModel.onCleared() verificationDeepDiveViewModel.onCleared() logStreamViewModel.onCleared() + batchTestViewModel.onCleared() } } @@ -154,6 +159,14 @@ fun App() { ) } + Screen.BatchTest -> { + val mainUiState by mainViewModel.uiState.collectAsState() + BatchTestScreen( + viewModel = batchTestViewModel, + devices = mainUiState.devices + ) + } + Screen.Settings -> { // TODO: Implement SettingsScreen MainScreen(viewModel = mainViewModel) @@ -229,6 +242,13 @@ private fun NavigationSidebar( onClick = { onNavigate(Screen.LogStream) } ) + NavigationRailItem( + icon = { Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = "Batch Test") }, + label = { Text("Batch Test") }, + selected = currentScreen == Screen.BatchTest, + onClick = { onNavigate(Screen.BatchTest) } + ) + Spacer(modifier = Modifier.weight(1f)) NavigationRailItem( diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituter.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituter.kt new file mode 100644 index 0000000..91b91b1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituter.kt @@ -0,0 +1,74 @@ +package com.manjee.linkops.data.mapper + +import com.manjee.linkops.domain.model.DeepLinkTemplate +import com.manjee.linkops.domain.usecase.batchtest.TemplateUriResolver + +/** + * Handles variable substitution in deep link URI templates + * + * Replaces `{variable}` placeholders with concrete values. + * Supports combinatorial expansion when multiple variables have multiple values. + */ +class ParameterSubstituter : TemplateUriResolver { + + /** + * Resolves all URIs from a list of templates and variable values + * + * @param templates List of deep link templates + * @param variables Map of variable names to their possible values + * @return List of fully resolved URI strings + */ + override fun resolveAll( + templates: List, + variables: Map> + ): List { + if (templates.isEmpty()) return emptyList() + + val combinations = generateCombinations(variables) + + return templates.flatMap { template -> + if (!template.hasPlaceholders || combinations.isEmpty()) { + listOf(template.uri) + } else { + combinations.map { combination -> substitute(template.uri, combination) } + } + } + } + + /** + * Substitutes placeholders in a URI template with values + * + * @param uriTemplate URI with {variable} placeholders + * @param values Map of variable name to concrete value + * @return Resolved URI string + */ + fun substitute(uriTemplate: String, values: Map): String { + var result = uriTemplate + values.forEach { (key, value) -> + result = result.replace("{$key}", value) + } + return result + } + + /** + * Generates Cartesian product of variable values + * + * @param variables Map of variable names to their possible values + * @return List of all possible variable combinations + */ + fun generateCombinations( + variables: Map> + ): List> { + if (variables.isEmpty()) return emptyList() + + val entries = variables.entries.toList() + val nonEmpty = entries.filter { it.value.isNotEmpty() } + if (nonEmpty.isEmpty()) return emptyList() + + return nonEmpty.fold(listOf(emptyMap())) { acc, (key, values) -> + acc.flatMap { existing -> + values.map { value -> existing + (key to value) } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ScenarioMapper.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ScenarioMapper.kt new file mode 100644 index 0000000..3c1b0ec --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/mapper/ScenarioMapper.kt @@ -0,0 +1,90 @@ +package com.manjee.linkops.data.mapper + +import com.manjee.linkops.domain.model.DeepLinkTemplate +import com.manjee.linkops.domain.model.TestScenario +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Maps between domain TestScenario and serializable DTO for JSON export/import + */ +class ScenarioMapper { + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + /** + * Serializes a TestScenario to JSON string + * + * @param scenario Domain model + * @return JSON string representation + */ + fun toJson(scenario: TestScenario): String { + val dto = TestScenarioDto( + id = scenario.id, + name = scenario.name, + description = scenario.description, + templates = scenario.templates.map { template -> + DeepLinkTemplateDto( + uri = template.uri, + name = template.name, + expectedActivity = template.expectedActivity + ) + }, + variables = scenario.variables, + delayBetweenLaunchesMs = scenario.delayBetweenLaunchesMs + ) + return json.encodeToString(dto) + } + + /** + * Deserializes a JSON string to TestScenario + * + * @param jsonString JSON string + * @return Domain model + */ + fun fromJson(jsonString: String): TestScenario { + val dto = json.decodeFromString(jsonString) + return TestScenario( + id = dto.id, + name = dto.name, + description = dto.description, + templates = dto.templates.map { templateDto -> + DeepLinkTemplate( + uri = templateDto.uri, + name = templateDto.name, + expectedActivity = templateDto.expectedActivity + ) + }, + variables = dto.variables, + delayBetweenLaunchesMs = dto.delayBetweenLaunchesMs + ) + } +} + +/** + * Serializable DTO for TestScenario JSON export/import + */ +@Serializable +private data class TestScenarioDto( + val id: String, + val name: String, + val description: String = "", + val templates: List = emptyList(), + val variables: Map> = emptyMap(), + val delayBetweenLaunchesMs: Long = TestScenario.DEFAULT_DELAY_MS +) + +/** + * Serializable DTO for DeepLinkTemplate + */ +@Serializable +private data class DeepLinkTemplateDto( + val uri: String, + val name: String = "", + val expectedActivity: String = "" +) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/AmStartOutputParser.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/AmStartOutputParser.kt new file mode 100644 index 0000000..727bd08 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/AmStartOutputParser.kt @@ -0,0 +1,102 @@ +package com.manjee.linkops.data.parser + +/** + * Parser for `adb shell am start` command output + * + * Example success output: + * ``` + * Starting: Intent { act=android.intent.action.VIEW dat=myapp://product/123 } + * Status: ok + * Activity: com.example.app/.ProductActivity + * ThisTime: 234 + * TotalTime: 456 + * WaitTime: 567 + * Complete + * ``` + * + * Example error output (no matching Activity): + * ``` + * Starting: Intent { act=android.intent.action.VIEW dat=invalid://link } + * Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW dat=invalid://link flg=0x10000000 } + * ``` + * + * Example error output (device error): + * ``` + * error: device not found + * ``` + */ +class AmStartOutputParser { + + /** + * Parses am start output into a structured result + * + * @param output Raw stdout from `adb shell am start -W` command + * @return ParsedAmStartResult containing activity or error info + */ + fun parse(output: String): ParsedAmStartResult { + if (output.isBlank()) { + return ParsedAmStartResult.Error("Empty output from am start command") + } + + val lines = output.lines().map { it.trim() } + + // Check for device-level errors + lines.firstOrNull { it.startsWith("error:") }?.let { errorLine -> + return ParsedAmStartResult.Error(errorLine) + } + + // Check for Permission denied + lines.firstOrNull { + it.contains("Permission denied", ignoreCase = true) || + it.contains("Operation not permitted", ignoreCase = true) + }?.let { errorLine -> + return ParsedAmStartResult.Error(errorLine) + } + + // Check for Error line from am start + lines.firstOrNull { it.startsWith("Error:") }?.let { errorLine -> + return ParsedAmStartResult.Error(errorLine.removePrefix("Error:").trim()) + } + + // Look for Activity line + val activityLine = lines.firstOrNull { it.startsWith("Activity:") } + val activity = activityLine?.removePrefix("Activity:")?.trim() + + // Look for Status line + val statusLine = lines.firstOrNull { it.startsWith("Status:") } + val status = statusLine?.removePrefix("Status:")?.trim() + + // Check for Warning (e.g., Activity not started) + val warningLine = lines.firstOrNull { it.startsWith("Warning:") } + + return when { + status == "ok" && activity != null -> ParsedAmStartResult.Success(activity) + activity != null -> ParsedAmStartResult.Success(activity) + warningLine != null -> ParsedAmStartResult.Error( + warningLine.removePrefix("Warning:").trim() + ) + else -> ParsedAmStartResult.Error( + "Unable to parse am start output: ${output.take(200)}" + ) + } + } +} + +/** + * Parsed result from am start command output + */ +sealed class ParsedAmStartResult { + /** + * Activity was successfully resolved + * + * @param resolvedActivity Fully qualified Activity class name + */ + data class Success(val resolvedActivity: String) : ParsedAmStartResult() + + /** + * Launch failed with an error + * + * @param message Error description + */ + data class Error(val message: String) : ParsedAmStartResult() +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/BatchTestRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/BatchTestRepositoryImpl.kt new file mode 100644 index 0000000..403d260 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/BatchTestRepositoryImpl.kt @@ -0,0 +1,68 @@ +package com.manjee.linkops.data.repository + +import com.manjee.linkops.data.mapper.ScenarioMapper +import com.manjee.linkops.data.parser.AmStartOutputParser +import com.manjee.linkops.data.parser.ParsedAmStartResult +import com.manjee.linkops.domain.model.LaunchResult +import com.manjee.linkops.domain.model.TestScenario +import com.manjee.linkops.domain.repository.BatchTestRepository +import com.manjee.linkops.infrastructure.adb.AdbShellExecutor + +/** + * Implementation of BatchTestRepository using ADB commands + */ +class BatchTestRepositoryImpl( + private val adbExecutor: AdbShellExecutor, + private val amStartOutputParser: AmStartOutputParser, + private val scenarioMapper: ScenarioMapper +) : BatchTestRepository { + + override suspend fun launchDeepLink( + deviceSerial: String, + uri: String + ): Result { + val startTime = System.currentTimeMillis() + val command = buildAmStartCommand(uri) + + return adbExecutor.executeOnDevice(deviceSerial, command) + .mapCatching { output -> + val elapsed = System.currentTimeMillis() - startTime + when (val parsed = amStartOutputParser.parse(output)) { + is ParsedAmStartResult.Success -> LaunchResult.Success( + uri = uri, + resolvedActivity = parsed.resolvedActivity, + timeTakenMs = elapsed + ) + is ParsedAmStartResult.Error -> LaunchResult.Error( + uri = uri, + errorMessage = parsed.message, + timeTakenMs = elapsed + ) + } + } + .recoverCatching { error -> + val elapsed = System.currentTimeMillis() - startTime + LaunchResult.Error( + uri = uri, + errorMessage = error.message ?: "Unknown ADB error", + timeTakenMs = elapsed + ) + } + } + + override suspend fun exportScenario(scenario: TestScenario): Result { + return runCatching { + scenarioMapper.toJson(scenario) + } + } + + override suspend fun importScenario(json: String): Result { + return runCatching { + scenarioMapper.fromJson(json) + } + } + + private fun buildAmStartCommand(uri: String): String { + return "am start -W -a android.intent.action.VIEW -d \"$uri\"" + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt index be1a67e..be790bf 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -3,6 +3,9 @@ package com.manjee.linkops.di import com.manjee.linkops.data.analyzer.CertificateFingerprintComparator import com.manjee.linkops.data.analyzer.VerificationFailureAnalyzer import com.manjee.linkops.data.mapper.DeviceMapper +import com.manjee.linkops.data.mapper.ParameterSubstituter +import com.manjee.linkops.data.mapper.ScenarioMapper +import com.manjee.linkops.data.parser.AmStartOutputParser import com.manjee.linkops.data.parser.AssetLinksParser import com.manjee.linkops.data.parser.DumpsysParser import com.manjee.linkops.data.parser.GetAppLinksParser @@ -10,6 +13,7 @@ import com.manjee.linkops.data.parser.LogcatParser import com.manjee.linkops.data.parser.ManifestParser import com.manjee.linkops.data.repository.AppLinkRepositoryImpl import com.manjee.linkops.data.repository.AssetLinksRepositoryImpl +import com.manjee.linkops.data.repository.BatchTestRepositoryImpl import com.manjee.linkops.data.repository.DeviceRepositoryImpl import com.manjee.linkops.data.repository.FavoriteRepositoryImpl import com.manjee.linkops.data.repository.LogStreamRepositoryImpl @@ -18,6 +22,7 @@ import com.manjee.linkops.data.repository.VerificationDiagnosticsRepositoryImpl import com.manjee.linkops.data.strategy.AdbCommandStrategyFactory import com.manjee.linkops.domain.repository.AppLinkRepository import com.manjee.linkops.domain.repository.AssetLinksRepository +import com.manjee.linkops.domain.repository.BatchTestRepository import com.manjee.linkops.domain.repository.DeviceRepository import com.manjee.linkops.domain.repository.FavoriteRepository import com.manjee.linkops.domain.repository.LogStreamRepository @@ -26,6 +31,10 @@ import com.manjee.linkops.domain.repository.VerificationDiagnosticsRepository import com.manjee.linkops.domain.usecase.applink.FireIntentUseCase import com.manjee.linkops.domain.usecase.applink.ForceReverifyUseCase import com.manjee.linkops.domain.usecase.applink.GetAppLinksUseCase +import com.manjee.linkops.domain.usecase.batchtest.ExecuteBatchTestUseCase +import com.manjee.linkops.domain.usecase.batchtest.ExportScenarioUseCase +import com.manjee.linkops.domain.usecase.batchtest.ImportScenarioUseCase +import com.manjee.linkops.domain.usecase.batchtest.ResolveTemplateUrisUseCase import com.manjee.linkops.domain.usecase.device.DetectDevicesUseCase import com.manjee.linkops.domain.usecase.diagnostics.AnalyzeVerificationUseCase import com.manjee.linkops.domain.usecase.diagnostics.ValidateAssetLinksUseCase @@ -92,6 +101,18 @@ object AppContainer { LogcatParser() } + private val amStartOutputParser: AmStartOutputParser by lazy { + AmStartOutputParser() + } + + private val scenarioMapper: ScenarioMapper by lazy { + ScenarioMapper() + } + + private val parameterSubstituter: ParameterSubstituter by lazy { + ParameterSubstituter() + } + // Data - Strategy private val strategyFactory: AdbCommandStrategyFactory by lazy { AdbCommandStrategyFactory() @@ -148,6 +169,10 @@ object AppContainer { LogStreamRepositoryImpl(adbShellExecutor, logcatParser) } + val batchTestRepository: BatchTestRepository by lazy { + BatchTestRepositoryImpl(adbShellExecutor, amStartOutputParser, scenarioMapper) + } + // UseCases - Device val detectDevicesUseCase: DetectDevicesUseCase by lazy { DetectDevicesUseCase(deviceRepository) @@ -209,4 +234,21 @@ object AppContainer { val observeLogStreamUseCase: ObserveLogStreamUseCase by lazy { ObserveLogStreamUseCase(logStreamRepository) } + + // UseCases - Batch Test + val executeBatchTestUseCase: ExecuteBatchTestUseCase by lazy { + ExecuteBatchTestUseCase(batchTestRepository) + } + + val exportScenarioUseCase: ExportScenarioUseCase by lazy { + ExportScenarioUseCase(batchTestRepository) + } + + val importScenarioUseCase: ImportScenarioUseCase by lazy { + ImportScenarioUseCase(batchTestRepository) + } + + val resolveTemplateUrisUseCase: ResolveTemplateUrisUseCase by lazy { + ResolveTemplateUrisUseCase(parameterSubstituter) + } } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/LaunchResult.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/LaunchResult.kt new file mode 100644 index 0000000..07381c9 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/LaunchResult.kt @@ -0,0 +1,58 @@ +package com.manjee.linkops.domain.model + +/** + * Result of a single deep link launch via ADB + */ +sealed class LaunchResult { + /** + * The URI that was launched or attempted + */ + abstract val uri: String + + /** + * Deep link was successfully launched + * + * @param uri The URI that was launched + * @param resolvedActivity The Activity class that handled the intent + * @param timeTakenMs Time taken for the launch in milliseconds + */ + data class Success( + override val uri: String, + val resolvedActivity: String, + val timeTakenMs: Long + ) : LaunchResult() + + /** + * Deep link launch failed with an error + * + * @param uri The URI that was attempted + * @param errorMessage Description of the error + * @param timeTakenMs Time taken before failure in milliseconds + */ + data class Error( + override val uri: String, + val errorMessage: String, + val timeTakenMs: Long + ) : LaunchResult() + + val isSuccess: Boolean get() = this is Success +} + +/** + * Aggregated result of a batch test execution + * + * @param scenarioName Name of the executed test scenario + * @param results Individual launch results + * @param totalTimeMs Total execution time in milliseconds + */ +data class BatchTestResult( + val scenarioName: String, + val results: List, + val totalTimeMs: Long +) { + val successCount: Int get() = results.count { it.isSuccess } + val failureCount: Int get() = results.size - successCount + val totalCount: Int get() = results.size + val successRate: Float + get() = if (totalCount > 0) successCount.toFloat() / totalCount else 0f +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/TestScenario.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/TestScenario.kt new file mode 100644 index 0000000..420caf1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/TestScenario.kt @@ -0,0 +1,55 @@ +package com.manjee.linkops.domain.model + +/** + * Represents a batch test scenario containing multiple deep link templates + * + * @param id Unique identifier for the scenario + * @param name Human-readable scenario name + * @param description Optional description of the test scenario + * @param templates List of deep link templates to test + * @param variables Map of variable names to their possible values for combinatorial testing + * @param delayBetweenLaunchesMs Delay in milliseconds between sequential launches + */ +data class TestScenario( + val id: String, + val name: String, + val description: String = "", + val templates: List = emptyList(), + val variables: Map> = emptyMap(), + val delayBetweenLaunchesMs: Long = DEFAULT_DELAY_MS +) { + val totalLaunchCount: Int + get() { + if (templates.isEmpty()) return 0 + val combinationCount = if (variables.isEmpty()) 1 + else variables.values.fold(1) { acc, values -> acc * values.size.coerceAtLeast(1) } + return templates.size * combinationCount + } + + companion object { + const val DEFAULT_DELAY_MS = 1000L + } +} + +/** + * Represents a deep link URI template with optional metadata + * + * @param uri URI string, may contain {variable} placeholders + * @param name Optional human-readable name for this template + * @param expectedActivity Optional expected Activity class that should resolve + */ +data class DeepLinkTemplate( + val uri: String, + val name: String = "", + val expectedActivity: String = "" +) { + val hasPlaceholders: Boolean + get() = PLACEHOLDER_REGEX.containsMatchIn(uri) + + val placeholderNames: Set + get() = PLACEHOLDER_REGEX.findAll(uri).map { it.groupValues[1] }.toSet() + + companion object { + val PLACEHOLDER_REGEX = Regex("\\{([^}]+)}") + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/BatchTestRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/BatchTestRepository.kt new file mode 100644 index 0000000..8395087 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/BatchTestRepository.kt @@ -0,0 +1,35 @@ +package com.manjee.linkops.domain.repository + +import com.manjee.linkops.domain.model.LaunchResult +import com.manjee.linkops.domain.model.TestScenario +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for batch deep link testing operations + */ +interface BatchTestRepository { + /** + * Launches a single deep link intent on the device and captures the result + * + * @param deviceSerial Device serial number + * @param uri Fully resolved deep link URI + * @return Result with launch outcome + */ + suspend fun launchDeepLink(deviceSerial: String, uri: String): Result + + /** + * Exports a test scenario to JSON string + * + * @param scenario Test scenario to serialize + * @return Result with JSON string + */ + suspend fun exportScenario(scenario: TestScenario): Result + + /** + * Imports a test scenario from JSON string + * + * @param json JSON string to deserialize + * @return Result with parsed test scenario + */ + suspend fun importScenario(json: String): Result +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCase.kt new file mode 100644 index 0000000..ea2ef06 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCase.kt @@ -0,0 +1,44 @@ +package com.manjee.linkops.domain.usecase.batchtest + +import com.manjee.linkops.domain.model.BatchTestResult +import com.manjee.linkops.domain.model.LaunchResult +import com.manjee.linkops.domain.model.TestScenario +import com.manjee.linkops.domain.repository.BatchTestRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * UseCase for executing a batch deep link test scenario + */ +class ExecuteBatchTestUseCase( + private val batchTestRepository: BatchTestRepository +) { + /** + * Executes a test scenario by launching all resolved URIs sequentially + * + * @param deviceSerial Device serial number + * @param scenario Test scenario to execute + * @param resolvedUris Pre-resolved URIs (with variables substituted) + * @return Flow emitting each LaunchResult as it completes + */ + operator fun invoke( + deviceSerial: String, + scenario: TestScenario, + resolvedUris: List + ): Flow = flow { + for (uri in resolvedUris) { + val result = batchTestRepository.launchDeepLink(deviceSerial, uri) + emit( + result.getOrElse { error -> + LaunchResult.Error( + uri = uri, + errorMessage = error.message ?: "Unknown error", + timeTakenMs = 0 + ) + } + ) + delay(scenario.delayBetweenLaunchesMs) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportScenarioUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportScenarioUseCase.kt new file mode 100644 index 0000000..ee1914f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportScenarioUseCase.kt @@ -0,0 +1,21 @@ +package com.manjee.linkops.domain.usecase.batchtest + +import com.manjee.linkops.domain.model.TestScenario +import com.manjee.linkops.domain.repository.BatchTestRepository + +/** + * UseCase for exporting a test scenario to JSON + */ +class ExportScenarioUseCase( + private val batchTestRepository: BatchTestRepository +) { + /** + * Exports a test scenario to JSON string + * + * @param scenario Test scenario to export + * @return Result with JSON string + */ + suspend operator fun invoke(scenario: TestScenario): Result { + return batchTestRepository.exportScenario(scenario) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ImportScenarioUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ImportScenarioUseCase.kt new file mode 100644 index 0000000..4c61fe5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ImportScenarioUseCase.kt @@ -0,0 +1,21 @@ +package com.manjee.linkops.domain.usecase.batchtest + +import com.manjee.linkops.domain.model.TestScenario +import com.manjee.linkops.domain.repository.BatchTestRepository + +/** + * UseCase for importing a test scenario from JSON + */ +class ImportScenarioUseCase( + private val batchTestRepository: BatchTestRepository +) { + /** + * Imports a test scenario from JSON string + * + * @param json JSON string to parse + * @return Result with parsed test scenario + */ + suspend operator fun invoke(json: String): Result { + return batchTestRepository.importScenario(json) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ResolveTemplateUrisUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ResolveTemplateUrisUseCase.kt new file mode 100644 index 0000000..e3093ea --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/batchtest/ResolveTemplateUrisUseCase.kt @@ -0,0 +1,41 @@ +package com.manjee.linkops.domain.usecase.batchtest + +import com.manjee.linkops.domain.model.DeepLinkTemplate + +/** + * UseCase for resolving deep link template URIs with variable substitution + */ +class ResolveTemplateUrisUseCase( + private val resolver: TemplateUriResolver +) { + /** + * Resolves all template URIs with the given variable values + * + * @param templates List of deep link templates + * @param variables Map of variable names to their possible values + * @return List of fully resolved URI strings + */ + operator fun invoke( + templates: List, + variables: Map> + ): List { + return resolver.resolveAll(templates, variables) + } +} + +/** + * Interface for resolving template URIs with variable substitution + */ +interface TemplateUriResolver { + /** + * Resolves all URIs from templates and variable values + * + * @param templates List of deep link templates + * @param variables Map of variable names to their possible values + * @return List of fully resolved URI strings + */ + fun resolveAll( + templates: List, + variables: Map> + ): List +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt index dfba84f..0f06aec 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt @@ -121,4 +121,5 @@ fun NavigationController.navigateToDeviceSelection() = navigateTo(Screen.DeviceS fun NavigationController.navigateToDiagnostics() = navigateTo(Screen.Diagnostics) fun NavigationController.navigateToVerificationDeepDive() = navigateTo(Screen.VerificationDeepDive) fun NavigationController.navigateToLogStream() = navigateTo(Screen.LogStream) +fun NavigationController.navigateToBatchTest() = navigateTo(Screen.BatchTest) fun NavigationController.navigateToSettings() = navigateTo(Screen.Settings) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt index 31e879d..13f6828 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt @@ -41,6 +41,11 @@ sealed class Screen(val route: String, val title: String) { */ data object LogStream : Screen("log_stream", "Log Streamer") + /** + * Batch test screen - for batch deep link testing with scenario management + */ + data object BatchTest : Screen("batch_test", "Batch Test") + /** * Settings screen */ @@ -56,6 +61,7 @@ sealed class Screen(val route: String, val title: String) { VerificationDeepDive, ManifestAnalyzer, LogStream, + BatchTest, Settings ) } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt new file mode 100644 index 0000000..4f74f27 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt @@ -0,0 +1,963 @@ +package com.manjee.linkops.ui.screen.batchtest + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.manjee.linkops.domain.model.BatchTestResult +import com.manjee.linkops.domain.model.Device +import com.manjee.linkops.domain.model.LaunchResult +import com.manjee.linkops.domain.model.TestScenario +import com.manjee.linkops.ui.component.EmptyState +import com.manjee.linkops.ui.component.LoadingOverlay +import com.manjee.linkops.ui.theme.LinkOpsColors + +/** + * Batch Test Screen + * + * Provides scenario editing, batch execution, and result reporting + * for deep link testing + */ +@Composable +fun BatchTestScreen( + viewModel: BatchTestViewModel, + devices: List, + modifier: Modifier = Modifier +) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + // Auto-select first online device + LaunchedEffect(devices) { + if (uiState.selectedDevice == null && devices.isNotEmpty()) { + val onlineDevice = devices.firstOrNull { it.state == Device.DeviceState.ONLINE } + onlineDevice?.let { viewModel.setDevice(it) } + } + } + + // Show error snackbar + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar(message = error, duration = SnackbarDuration.Short) + viewModel.clearError() + } + } + + // Show import success snackbar + LaunchedEffect(uiState.importSuccess) { + if (uiState.importSuccess) { + snackbarHostState.showSnackbar( + message = "Scenario imported successfully", + duration = SnackbarDuration.Short + ) + viewModel.clearImportSuccess() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Row( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background) + ) { + // Left Panel - Scenario Editor + Column( + modifier = Modifier + .weight(0.45f) + .fillMaxHeight() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ScenarioEditorPanel( + scenario = uiState.scenario, + devices = devices, + selectedDevice = uiState.selectedDevice, + resolvedUris = uiState.resolvedUris, + isExecuting = uiState.isExecuting, + onDeviceSelected = { viewModel.setDevice(it) }, + onNameChange = { viewModel.updateScenarioName(it) }, + onDescriptionChange = { viewModel.updateScenarioDescription(it) }, + onDelayChange = { viewModel.updateDelay(it) }, + onAddTemplate = { uri, name, expected -> viewModel.addTemplate(uri, name, expected) }, + onRemoveTemplate = { viewModel.removeTemplate(it) }, + onAddVariable = { name, values -> viewModel.addVariable(name, values) }, + onRemoveVariable = { viewModel.removeVariable(it) }, + onExecute = { viewModel.executeBatchTest() }, + onStop = { viewModel.stopExecution() }, + onExport = { viewModel.exportScenario() }, + onImport = { viewModel.importScenario(it) }, + onNew = { viewModel.newScenario() } + ) + } + + // Right Panel - Results + Column( + modifier = Modifier + .weight(0.55f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + ) { + ResultsPanel( + batchResult = uiState.batchResult, + results = uiState.results, + isExecuting = uiState.isExecuting, + executionProgress = uiState.executionProgress, + executionTotal = uiState.executionTotal, + onClear = { viewModel.clearResults() } + ) + } + } + } + + // Export dialog + uiState.exportedJson?.let { json -> + ExportDialog( + json = json, + onDismiss = { viewModel.clearExportedJson() } + ) + } + + // Loading overlay during execution + LoadingOverlay( + isLoading = uiState.isExecuting, + message = "Executing batch test... (${uiState.executionProgress}/${uiState.executionTotal})" + ) +} + +/** + * Scenario editor panel (left side) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScenarioEditorPanel( + scenario: TestScenario, + devices: List, + selectedDevice: Device?, + resolvedUris: List, + isExecuting: Boolean, + onDeviceSelected: (Device) -> Unit, + onNameChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onDelayChange: (Long) -> Unit, + onAddTemplate: (String, String, String) -> Unit, + onRemoveTemplate: (Int) -> Unit, + onAddVariable: (String, List) -> Unit, + onRemoveVariable: (String) -> Unit, + onExecute: () -> Unit, + onStop: () -> Unit, + onExport: () -> Unit, + onImport: (String) -> Unit, + onNew: () -> Unit +) { + var showImportDialog by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Batch Test", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = onNew, enabled = !isExecuting) { + Icon(Icons.Default.Add, contentDescription = "New Scenario") + } + IconButton(onClick = onExport, enabled = !isExecuting) { + Icon(Icons.Default.Share, contentDescription = "Export") + } + IconButton(onClick = { showImportDialog = true }, enabled = !isExecuting) { + Icon(Icons.Default.FileOpen, contentDescription = "Import") + } + } + } + + HorizontalDivider() + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Device selector + item { + DeviceSelector( + devices = devices, + selectedDevice = selectedDevice, + onDeviceSelected = onDeviceSelected + ) + } + + // Scenario metadata + item { + ScenarioMetadataSection( + scenario = scenario, + onNameChange = onNameChange, + onDescriptionChange = onDescriptionChange, + onDelayChange = onDelayChange + ) + } + + // Deep link templates + item { + TemplatesSection( + templates = scenario.templates, + onAddTemplate = onAddTemplate, + onRemoveTemplate = onRemoveTemplate, + isExecuting = isExecuting + ) + } + + // Variables + item { + VariablesSection( + variables = scenario.variables, + onAddVariable = onAddVariable, + onRemoveVariable = onRemoveVariable, + isExecuting = isExecuting + ) + } + + // Resolved URIs preview + if (resolvedUris.isNotEmpty()) { + item { + ResolvedUrisPreview(resolvedUris = resolvedUris) + } + } + } + + // Execute button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (isExecuting) { + Button( + onClick = onStop, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = LinkOpsColors.Error + ) + ) { + Icon(Icons.Default.Stop, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Stop Execution") + } + } else { + Button( + onClick = onExecute, + modifier = Modifier.fillMaxWidth(), + enabled = selectedDevice != null && scenario.templates.isNotEmpty() + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Execute Batch Test (${scenario.totalLaunchCount} launches)") + } + } + } + } + + // Import dialog + if (showImportDialog) { + ImportDialog( + onDismiss = { showImportDialog = false }, + onImport = { json -> + onImport(json) + showImportDialog = false + } + ) + } +} + +/** + * Device selector dropdown + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeviceSelector( + devices: List, + selectedDevice: Device?, + onDeviceSelected: (Device) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Device", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedDevice?.let { "${it.model} (${it.serialNumber})" } ?: "Select device", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + devices.filter { it.state == Device.DeviceState.ONLINE }.forEach { device -> + DropdownMenuItem( + text = { Text("${device.model} (${device.serialNumber})") }, + onClick = { + onDeviceSelected(device) + expanded = false + } + ) + } + } + } + } +} + +/** + * Scenario metadata inputs + */ +@Composable +private fun ScenarioMetadataSection( + scenario: TestScenario, + onNameChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onDelayChange: (Long) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Scenario", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + OutlinedTextField( + value = scenario.name, + onValueChange = onNameChange, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = scenario.description, + onValueChange = onDescriptionChange, + label = { Text("Description (optional)") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2 + ) + + OutlinedTextField( + value = scenario.delayBetweenLaunchesMs.toString(), + onValueChange = { value -> + value.toLongOrNull()?.let { onDelayChange(it.coerceAtLeast(0)) } + }, + label = { Text("Delay between launches (ms)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } +} + +/** + * Deep link templates section + */ +@Composable +private fun TemplatesSection( + templates: List, + onAddTemplate: (String, String, String) -> Unit, + onRemoveTemplate: (Int) -> Unit, + isExecuting: Boolean +) { + var newUri by remember { mutableStateOf("") } + var newName by remember { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Deep Link Templates (${templates.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + // Add new template + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = newUri, + onValueChange = { newUri = it }, + label = { Text("URI (e.g., myapp://product/{id})") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isExecuting + ) + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("Name (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isExecuting + ) + } + + IconButton( + onClick = { + onAddTemplate(newUri, newName, "") + newUri = "" + newName = "" + }, + enabled = newUri.isNotBlank() && !isExecuting + ) { + Icon(Icons.Default.Add, contentDescription = "Add template") + } + } + + // Template list + templates.forEachIndexed { index, template -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + if (template.name.isNotBlank()) { + Text( + text = template.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = template.uri, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton( + onClick = { onRemoveTemplate(index) }, + enabled = !isExecuting + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove", + tint = LinkOpsColors.Error + ) + } + } + } + } + } +} + +/** + * Variables section for combinatorial testing + */ +@Composable +private fun VariablesSection( + variables: Map>, + onAddVariable: (String, List) -> Unit, + onRemoveVariable: (String) -> Unit, + isExecuting: Boolean +) { + var newVarName by remember { mutableStateOf("") } + var newVarValues by remember { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Variables (${variables.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + // Add new variable + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = newVarName, + onValueChange = { newVarName = it }, + label = { Text("Variable name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isExecuting + ) + OutlinedTextField( + value = newVarValues, + onValueChange = { newVarValues = it }, + label = { Text("Values (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isExecuting + ) + } + + IconButton( + onClick = { + val values = newVarValues.split(",").map { it.trim() }.filter { it.isNotEmpty() } + onAddVariable(newVarName, values) + newVarName = "" + newVarValues = "" + }, + enabled = newVarName.isNotBlank() && newVarValues.isNotBlank() && !isExecuting + ) { + Icon(Icons.Default.Add, contentDescription = "Add variable") + } + } + + // Variable list + variables.forEach { (name, values) -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "{$name}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = values.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton( + onClick = { onRemoveVariable(name) }, + enabled = !isExecuting + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove", + tint = LinkOpsColors.Error + ) + } + } + } + } + } +} + +/** + * Preview of resolved URIs after variable substitution + */ +@Composable +private fun ResolvedUrisPreview(resolvedUris: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Resolved URIs Preview (${resolvedUris.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = LinkOpsColors.TerminalBackground + ) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + resolvedUris.take(20).forEachIndexed { index, uri -> + Text( + text = "${index + 1}. $uri", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + color = LinkOpsColors.TerminalText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (resolvedUris.size > 20) { + Text( + text = "... and ${resolvedUris.size - 20} more", + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.TerminalInfo + ) + } + } + } + } +} + +/** + * Results panel (right side) + */ +@Composable +private fun ResultsPanel( + batchResult: BatchTestResult?, + results: List, + isExecuting: Boolean, + executionProgress: Int, + executionTotal: Int, + onClear: () -> Unit +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Results", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + if (batchResult != null || results.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + } + + // Progress indicator during execution + if (isExecuting) { + LinearProgressIndicator( + progress = { if (executionTotal > 0) executionProgress.toFloat() / executionTotal else 0f }, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Progress: $executionProgress / $executionTotal", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Summary card + batchResult?.let { result -> + ResultSummaryCard(result = result) + } + + if (results.isEmpty() && !isExecuting) { + EmptyState( + title = "No results yet", + description = "Configure a scenario and execute batch test", + icon = Icons.Default.PlayArrow + ) + } else { + // Result list + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(results) { result -> + ResultItemCard(result = result) + } + } + } + } +} + +/** + * Result summary card + */ +@Composable +private fun ResultSummaryCard(result: BatchTestResult) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Summary: ${result.scenarioName}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + SummaryStatItem(label = "Total", value = result.totalCount.toString()) + SummaryStatItem( + label = "Success", + value = result.successCount.toString(), + color = LinkOpsColors.Success + ) + SummaryStatItem( + label = "Failed", + value = result.failureCount.toString(), + color = if (result.failureCount > 0) LinkOpsColors.Error else MaterialTheme.colorScheme.onSurface + ) + SummaryStatItem( + label = "Time", + value = "${result.totalTimeMs}ms" + ) + } + + // Success rate bar + LinearProgressIndicator( + progress = { result.successRate }, + modifier = Modifier.fillMaxWidth().height(8.dp), + color = LinkOpsColors.Success, + trackColor = LinkOpsColors.ErrorLight + ) + + Text( + text = "Success rate: ${"%.1f".format(result.successRate * 100)}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SummaryStatItem( + label: String, + value: String, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Individual result item card + */ +@Composable +private fun ResultItemCard(result: LaunchResult) { + val (bgColor, statusIcon, statusColor) = when (result) { + is LaunchResult.Success -> Triple(LinkOpsColors.SuccessLight, "OK", LinkOpsColors.Success) + is LaunchResult.Error -> Triple(LinkOpsColors.ErrorLight, "FAIL", LinkOpsColors.Error) + } + + Card( + colors = CardDefaults.cardColors(containerColor = bgColor) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Status badge + Box( + modifier = Modifier + .background(statusColor, RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = statusIcon, + style = MaterialTheme.typography.labelSmall, + color = LinkOpsColors.OnPrimary, + fontWeight = FontWeight.Bold + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = result.uri, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + when (result) { + is LaunchResult.Success -> { + Text( + text = "Activity: ${result.resolvedActivity}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is LaunchResult.Error -> { + Text( + text = result.errorMessage, + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Error + ) + } + } + } + + // Timing + val timeTaken = when (result) { + is LaunchResult.Success -> result.timeTakenMs + is LaunchResult.Error -> result.timeTakenMs + } + Text( + text = "${timeTaken}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Export dialog showing JSON content + */ +@Composable +private fun ExportDialog( + json: String, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Export Scenario") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Copy the JSON below to share this scenario:", + style = MaterialTheme.typography.bodyMedium + ) + Card( + colors = CardDefaults.cardColors( + containerColor = LinkOpsColors.TerminalBackground + ) + ) { + Text( + text = json, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + color = LinkOpsColors.TerminalText, + modifier = Modifier + .padding(12.dp) + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()) + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + +/** + * Import dialog for pasting JSON + */ +@Composable +private fun ImportDialog( + onDismiss: () -> Unit, + onImport: (String) -> Unit +) { + var jsonInput by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Import Scenario") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Paste the scenario JSON below:", + style = MaterialTheme.typography.bodyMedium + ) + OutlinedTextField( + value = jsonInput, + onValueChange = { jsonInput = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp), + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + ) + } + }, + confirmButton = { + TextButton( + onClick = { onImport(jsonInput) }, + enabled = jsonInput.isNotBlank() + ) { + Text("Import") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestViewModel.kt new file mode 100644 index 0000000..9d798d0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestViewModel.kt @@ -0,0 +1,313 @@ +package com.manjee.linkops.ui.screen.batchtest + +import com.manjee.linkops.di.AppContainer +import com.manjee.linkops.domain.model.BatchTestResult +import com.manjee.linkops.domain.model.DeepLinkTemplate +import com.manjee.linkops.domain.model.Device +import com.manjee.linkops.domain.model.LaunchResult +import com.manjee.linkops.domain.model.TestScenario +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.update +import java.util.UUID + +/** + * UI State for Batch Test Screen + */ +data class BatchTestUiState( + val selectedDevice: Device? = null, + val scenario: TestScenario = createDefaultScenario(), + val resolvedUris: List = emptyList(), + val isExecuting: Boolean = false, + val executionProgress: Int = 0, + val executionTotal: Int = 0, + val results: List = emptyList(), + val batchResult: BatchTestResult? = null, + val error: String? = null, + val exportedJson: String? = null, + val importSuccess: Boolean = false +) + +/** + * ViewModel for Batch Test Screen + * + * Manages test scenario editing, batch execution, and result display + */ +class BatchTestViewModel { + private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _uiState = MutableStateFlow(BatchTestUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var executionJob: Job? = null + + /** + * Set the selected device + */ + fun setDevice(device: Device?) { + _uiState.update { it.copy(selectedDevice = device, error = null) } + } + + /** + * Update scenario name + */ + fun updateScenarioName(name: String) { + _uiState.update { it.copy(scenario = it.scenario.copy(name = name)) } + } + + /** + * Update scenario description + */ + fun updateScenarioDescription(description: String) { + _uiState.update { it.copy(scenario = it.scenario.copy(description = description)) } + } + + /** + * Update delay between launches + */ + fun updateDelay(delayMs: Long) { + _uiState.update { + it.copy(scenario = it.scenario.copy(delayBetweenLaunchesMs = delayMs)) + } + } + + /** + * Add a new deep link template + */ + fun addTemplate(uri: String, name: String = "", expectedActivity: String = "") { + if (uri.isBlank()) return + val template = DeepLinkTemplate(uri = uri.trim(), name = name.trim(), expectedActivity = expectedActivity.trim()) + _uiState.update { + val updatedTemplates = it.scenario.templates + template + it.copy(scenario = it.scenario.copy(templates = updatedTemplates)) + } + refreshResolvedUris() + } + + /** + * Remove a template by index + */ + fun removeTemplate(index: Int) { + _uiState.update { + val updatedTemplates = it.scenario.templates.toMutableList().apply { + if (index in indices) removeAt(index) + } + it.copy(scenario = it.scenario.copy(templates = updatedTemplates)) + } + refreshResolvedUris() + } + + /** + * Add a variable with values + */ + fun addVariable(name: String, values: List) { + if (name.isBlank() || values.isEmpty()) return + _uiState.update { + val updatedVars = it.scenario.variables + (name.trim() to values.map { v -> v.trim() }) + it.copy(scenario = it.scenario.copy(variables = updatedVars)) + } + refreshResolvedUris() + } + + /** + * Remove a variable by name + */ + fun removeVariable(name: String) { + _uiState.update { + val updatedVars = it.scenario.variables - name + it.copy(scenario = it.scenario.copy(variables = updatedVars)) + } + refreshResolvedUris() + } + + /** + * Refresh the resolved URI preview + */ + private fun refreshResolvedUris() { + val state = _uiState.value + val resolved = AppContainer.resolveTemplateUrisUseCase( + state.scenario.templates, + state.scenario.variables + ) + _uiState.update { it.copy(resolvedUris = resolved) } + } + + /** + * Execute the batch test + */ + fun executeBatchTest() { + val device = _uiState.value.selectedDevice ?: run { + _uiState.update { it.copy(error = "No device selected") } + return + } + + val scenario = _uiState.value.scenario + if (scenario.templates.isEmpty()) { + _uiState.update { it.copy(error = "No deep link templates defined") } + return + } + + val resolvedUris = AppContainer.resolveTemplateUrisUseCase( + scenario.templates, + scenario.variables + ) + + if (resolvedUris.isEmpty()) { + _uiState.update { it.copy(error = "No URIs to test") } + return + } + + executionJob?.cancel() + executionJob = viewModelScope.launch { + _uiState.update { + it.copy( + isExecuting = true, + executionProgress = 0, + executionTotal = resolvedUris.size, + results = emptyList(), + batchResult = null, + error = null + ) + } + + val startTime = System.currentTimeMillis() + val collectedResults = mutableListOf() + + AppContainer.executeBatchTestUseCase(device.serialNumber, scenario, resolvedUris) + .catch { e -> + _uiState.update { + it.copy( + isExecuting = false, + error = e.message ?: "Batch execution failed" + ) + } + } + .collect { result -> + collectedResults.add(result) + _uiState.update { + it.copy( + executionProgress = collectedResults.size, + results = collectedResults.toList() + ) + } + } + + val totalTime = System.currentTimeMillis() - startTime + _uiState.update { + it.copy( + isExecuting = false, + batchResult = BatchTestResult( + scenarioName = scenario.name, + results = collectedResults.toList(), + totalTimeMs = totalTime + ) + ) + } + } + } + + /** + * Stop the currently executing batch test + */ + fun stopExecution() { + executionJob?.cancel() + _uiState.update { it.copy(isExecuting = false) } + } + + /** + * Export the current scenario to JSON + */ + fun exportScenario() { + viewModelScope.launch { + AppContainer.exportScenarioUseCase(_uiState.value.scenario) + .onSuccess { json -> + _uiState.update { it.copy(exportedJson = json, error = null) } + } + .onFailure { error -> + _uiState.update { it.copy(error = error.message ?: "Export failed") } + } + } + } + + /** + * Import a scenario from JSON + */ + fun importScenario(json: String) { + viewModelScope.launch { + AppContainer.importScenarioUseCase(json) + .onSuccess { scenario -> + _uiState.update { + it.copy( + scenario = scenario, + importSuccess = true, + error = null, + batchResult = null, + results = emptyList() + ) + } + refreshResolvedUris() + } + .onFailure { error -> + _uiState.update { + it.copy(error = "Import failed: ${error.message}") + } + } + } + } + + /** + * Clear the export result + */ + fun clearExportedJson() { + _uiState.update { it.copy(exportedJson = null) } + } + + /** + * Clear the import success flag + */ + fun clearImportSuccess() { + _uiState.update { it.copy(importSuccess = false) } + } + + /** + * Clear results + */ + fun clearResults() { + _uiState.update { it.copy(batchResult = null, results = emptyList()) } + } + + /** + * Clear error + */ + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + /** + * Reset to a new scenario + */ + fun newScenario() { + _uiState.update { + BatchTestUiState( + selectedDevice = it.selectedDevice, + scenario = createDefaultScenario() + ) + } + } + + /** + * Cleanup resources + */ + fun onCleared() { + executionJob?.cancel() + viewModelScope.cancel() + } +} + +private fun createDefaultScenario(): TestScenario = TestScenario( + id = UUID.randomUUID().toString(), + name = "New Test Scenario" +) diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituterTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituterTest.kt new file mode 100644 index 0000000..e10895c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ParameterSubstituterTest.kt @@ -0,0 +1,190 @@ +package com.manjee.linkops.data.mapper + +import com.manjee.linkops.domain.model.DeepLinkTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ParameterSubstituterTest { + + private val substituter = ParameterSubstituter() + + // --- substitute --- + + @Test + fun `substitute should replace single placeholder`() { + val result = substituter.substitute("myapp://product/{id}", mapOf("id" to "123")) + assertEquals("myapp://product/123", result) + } + + @Test + fun `substitute should replace multiple placeholders`() { + val result = substituter.substitute( + "myapp://product/{id}/variant/{color}", + mapOf("id" to "42", "color" to "red") + ) + assertEquals("myapp://product/42/variant/red", result) + } + + @Test + fun `substitute should return original when no matching placeholders`() { + val result = substituter.substitute( + "myapp://product/{id}", + mapOf("name" to "test") + ) + assertEquals("myapp://product/{id}", result) + } + + @Test + fun `substitute should handle empty values map`() { + val result = substituter.substitute("myapp://product/{id}", emptyMap()) + assertEquals("myapp://product/{id}", result) + } + + @Test + fun `substitute should handle URI without placeholders`() { + val result = substituter.substitute( + "myapp://home", + mapOf("id" to "123") + ) + assertEquals("myapp://home", result) + } + + // --- generateCombinations --- + + @Test + fun `generateCombinations should return empty list for empty variables`() { + val result = substituter.generateCombinations(emptyMap()) + assertTrue(result.isEmpty()) + } + + @Test + fun `generateCombinations should generate single variable combinations`() { + val result = substituter.generateCombinations( + mapOf("id" to listOf("1", "2", "3")) + ) + assertEquals(3, result.size) + assertEquals(mapOf("id" to "1"), result[0]) + assertEquals(mapOf("id" to "2"), result[1]) + assertEquals(mapOf("id" to "3"), result[2]) + } + + @Test + fun `generateCombinations should generate cartesian product`() { + val result = substituter.generateCombinations( + mapOf( + "id" to listOf("1", "2"), + "color" to listOf("red", "blue") + ) + ) + assertEquals(4, result.size) + assertTrue(result.contains(mapOf("id" to "1", "color" to "red"))) + assertTrue(result.contains(mapOf("id" to "1", "color" to "blue"))) + assertTrue(result.contains(mapOf("id" to "2", "color" to "red"))) + assertTrue(result.contains(mapOf("id" to "2", "color" to "blue"))) + } + + @Test + fun `generateCombinations should skip empty value lists`() { + val result = substituter.generateCombinations( + mapOf( + "id" to listOf("1", "2"), + "empty" to emptyList() + ) + ) + assertEquals(2, result.size) + assertEquals(mapOf("id" to "1"), result[0]) + assertEquals(mapOf("id" to "2"), result[1]) + } + + @Test + fun `generateCombinations should return empty for all empty value lists`() { + val result = substituter.generateCombinations( + mapOf( + "a" to emptyList(), + "b" to emptyList() + ) + ) + assertTrue(result.isEmpty()) + } + + // --- resolveAll --- + + @Test + fun `resolveAll should return empty list for empty templates`() { + val result = substituter.resolveAll(emptyList(), emptyMap()) + assertTrue(result.isEmpty()) + } + + @Test + fun `resolveAll should return URIs without substitution when no variables`() { + val templates = listOf( + DeepLinkTemplate(uri = "myapp://home"), + DeepLinkTemplate(uri = "myapp://settings") + ) + val result = substituter.resolveAll(templates, emptyMap()) + assertEquals(listOf("myapp://home", "myapp://settings"), result) + } + + @Test + fun `resolveAll should return original URIs for templates without placeholders`() { + val templates = listOf( + DeepLinkTemplate(uri = "myapp://home"), + DeepLinkTemplate(uri = "myapp://settings") + ) + val result = substituter.resolveAll(templates, mapOf("id" to listOf("1", "2"))) + assertEquals(listOf("myapp://home", "myapp://settings"), result) + } + + @Test + fun `resolveAll should expand templates with variables`() { + val templates = listOf( + DeepLinkTemplate(uri = "myapp://product/{id}") + ) + val variables = mapOf("id" to listOf("1", "2", "3")) + + val result = substituter.resolveAll(templates, variables) + + assertEquals(3, result.size) + assertEquals("myapp://product/1", result[0]) + assertEquals("myapp://product/2", result[1]) + assertEquals("myapp://product/3", result[2]) + } + + @Test + fun `resolveAll should expand multiple templates with multiple variables`() { + val templates = listOf( + DeepLinkTemplate(uri = "myapp://product/{id}"), + DeepLinkTemplate(uri = "myapp://category/{cat}") + ) + val variables = mapOf( + "id" to listOf("1", "2"), + "cat" to listOf("shoes", "hats") + ) + + val result = substituter.resolveAll(templates, variables) + + // 2 templates * 4 combinations = 8 results + assertEquals(8, result.size) + assertTrue(result.contains("myapp://product/1")) + assertTrue(result.contains("myapp://product/2")) + assertTrue(result.contains("myapp://category/shoes")) + assertTrue(result.contains("myapp://category/hats")) + } + + @Test + fun `resolveAll should handle mix of templates with and without placeholders`() { + val templates = listOf( + DeepLinkTemplate(uri = "myapp://home"), + DeepLinkTemplate(uri = "myapp://product/{id}") + ) + val variables = mapOf("id" to listOf("1", "2")) + + val result = substituter.resolveAll(templates, variables) + + assertEquals(3, result.size) + assertEquals("myapp://home", result[0]) + assertEquals("myapp://product/1", result[1]) + assertEquals("myapp://product/2", result[2]) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ScenarioMapperTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ScenarioMapperTest.kt new file mode 100644 index 0000000..e8e143b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/mapper/ScenarioMapperTest.kt @@ -0,0 +1,116 @@ +package com.manjee.linkops.data.mapper + +import com.manjee.linkops.domain.model.DeepLinkTemplate +import com.manjee.linkops.domain.model.TestScenario +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ScenarioMapperTest { + + private val mapper = ScenarioMapper() + + @Test + fun `toJson and fromJson should roundtrip a complete scenario`() { + val scenario = TestScenario( + id = "test-id-123", + name = "Product Test", + description = "Test product deep links", + templates = listOf( + DeepLinkTemplate( + uri = "myapp://product/{id}", + name = "Product Page", + expectedActivity = "com.example/.ProductActivity" + ), + DeepLinkTemplate(uri = "myapp://home") + ), + variables = mapOf( + "id" to listOf("1", "2", "3") + ), + delayBetweenLaunchesMs = 2000L + ) + + val json = mapper.toJson(scenario) + val restored = mapper.fromJson(json) + + assertEquals(scenario.id, restored.id) + assertEquals(scenario.name, restored.name) + assertEquals(scenario.description, restored.description) + assertEquals(scenario.templates.size, restored.templates.size) + assertEquals(scenario.templates[0].uri, restored.templates[0].uri) + assertEquals(scenario.templates[0].name, restored.templates[0].name) + assertEquals(scenario.templates[0].expectedActivity, restored.templates[0].expectedActivity) + assertEquals(scenario.templates[1].uri, restored.templates[1].uri) + assertEquals(scenario.variables, restored.variables) + assertEquals(scenario.delayBetweenLaunchesMs, restored.delayBetweenLaunchesMs) + } + + @Test + fun `toJson and fromJson should roundtrip minimal scenario`() { + val scenario = TestScenario( + id = "min-id", + name = "Minimal" + ) + + val json = mapper.toJson(scenario) + val restored = mapper.fromJson(json) + + assertEquals(scenario.id, restored.id) + assertEquals(scenario.name, restored.name) + assertEquals("", restored.description) + assertTrue(restored.templates.isEmpty()) + assertTrue(restored.variables.isEmpty()) + assertEquals(TestScenario.DEFAULT_DELAY_MS, restored.delayBetweenLaunchesMs) + } + + @Test + fun `toJson should produce valid JSON`() { + val scenario = TestScenario( + id = "json-test", + name = "JSON Test", + templates = listOf(DeepLinkTemplate(uri = "myapp://test")) + ) + + val json = mapper.toJson(scenario) + + assertTrue(json.contains("\"id\"")) + assertTrue(json.contains("\"name\"")) + assertTrue(json.contains("\"templates\"")) + assertTrue(json.contains("myapp://test")) + } + + @Test + fun `fromJson should handle unknown keys gracefully`() { + val json = """ + { + "id": "test", + "name": "Test", + "unknownField": "should be ignored", + "templates": [], + "variables": {} + } + """.trimIndent() + + val scenario = mapper.fromJson(json) + + assertEquals("test", scenario.id) + assertEquals("Test", scenario.name) + } + + @Test + fun `fromJson should handle special characters in URIs`() { + val json = """ + { + "id": "special", + "name": "Special Chars", + "templates": [ + {"uri": "myapp://path?key=value&foo=bar%20baz"} + ] + } + """.trimIndent() + + val scenario = mapper.fromJson(json) + + assertEquals("myapp://path?key=value&foo=bar%20baz", scenario.templates[0].uri) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/AmStartOutputParserTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/AmStartOutputParserTest.kt new file mode 100644 index 0000000..5be0492 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/AmStartOutputParserTest.kt @@ -0,0 +1,173 @@ +package com.manjee.linkops.data.parser + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class AmStartOutputParserTest { + + private val parser = AmStartOutputParser() + + @Test + fun `parse should return Success when output contains Activity and ok status`() { + val output = """ + Starting: Intent { act=android.intent.action.VIEW dat=myapp://product/123 } + Status: ok + Activity: com.example.app/.ProductActivity + ThisTime: 234 + TotalTime: 456 + WaitTime: 567 + Complete + """.trimIndent() + + val result = parser.parse(output) + + assertIs(result) + assertEquals("com.example.app/.ProductActivity", result.resolvedActivity) + } + + @Test + fun `parse should return Success when output contains Activity without status line`() { + val output = """ + Starting: Intent { act=android.intent.action.VIEW dat=myapp://path } + Activity: com.example.app/.MainActivity + """.trimIndent() + + val result = parser.parse(output) + + assertIs(result) + assertEquals("com.example.app/.MainActivity", result.resolvedActivity) + } + + @Test + fun `parse should return Error when output contains Error line`() { + val output = """ + Starting: Intent { act=android.intent.action.VIEW dat=invalid://link } + Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW dat=invalid://link flg=0x10000000 } + """.trimIndent() + + val result = parser.parse(output) + + assertIs(result) + assertTrue(result.message.contains("Activity not started")) + } + + @Test + fun `parse should return Error for empty output`() { + val result = parser.parse("") + + assertIs(result) + assertTrue(result.message.contains("Empty output")) + } + + @Test + fun `parse should return Error for blank output`() { + val result = parser.parse(" \n \n ") + + assertIs(result) + assertTrue(result.message.contains("Empty output")) + } + + @Test + fun `parse should return Error for device not found`() { + val output = "error: device not found" + + val result = parser.parse(output) + + assertIs(result) + assertEquals("error: device not found", result.message) + } + + @Test + fun `parse should return Error for device closed`() { + val output = "error: closed" + + val result = parser.parse(output) + + assertIs(result) + assertEquals("error: closed", result.message) + } + + @Test + fun `parse should return Error for permission denied`() { + val output = "Permission denied" + + val result = parser.parse(output) + + assertIs(result) + assertTrue(result.message.contains("Permission denied")) + } + + @Test + fun `parse should return Error for operation not permitted`() { + val output = "Operation not permitted" + + val result = parser.parse(output) + + assertIs(result) + assertTrue(result.message.contains("Operation not permitted")) + } + + @Test + fun `parse should return Error for warning line`() { + val output = """ + Starting: Intent { act=android.intent.action.VIEW dat=myapp://test } + Warning: Activity not started, its current task has been brought to the front + """.trimIndent() + + val result = parser.parse(output) + + assertIs(result) + assertTrue(result.message.contains("Activity not started")) + } + + @Test + fun `parse should return Error for unparseable output`() { + val output = "Some random garbage output that doesn't match any pattern" + + val result = parser.parse(output) + + assertIs(result) + assertTrue(result.message.contains("Unable to parse")) + } + + @Test + fun `parse should handle truncated output`() { + val output = "Starting: Intent { act=android.intent.action.VIEW dat=myapp://pro" + + val result = parser.parse(output) + + assertIs(result) + } + + @Test + fun `parse should handle special characters in output`() { + val output = """ + Starting: Intent { act=android.intent.action.VIEW dat=myapp://path?key=value&foo=bar%20baz } + Status: ok + Activity: com.example.app/.DeepLinkActivity + """.trimIndent() + + val result = parser.parse(output) + + assertIs(result) + assertEquals("com.example.app/.DeepLinkActivity", result.resolvedActivity) + } + + @Test + fun `parse should handle output with extra whitespace`() { + val output = """ + + Starting: Intent { act=android.intent.action.VIEW } + Status: ok + Activity: com.example.app/.TestActivity + + """.trimIndent() + + val result = parser.parse(output) + + assertIs(result) + assertEquals("com.example.app/.TestActivity", result.resolvedActivity) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/model/TestScenarioTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/model/TestScenarioTest.kt new file mode 100644 index 0000000..8ed82a8 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/model/TestScenarioTest.kt @@ -0,0 +1,132 @@ +package com.manjee.linkops.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TestScenarioTest { + + @Test + fun `totalLaunchCount should be zero for empty templates`() { + val scenario = TestScenario(id = "1", name = "Test") + assertEquals(0, scenario.totalLaunchCount) + } + + @Test + fun `totalLaunchCount should equal template count when no variables`() { + val scenario = TestScenario( + id = "1", + name = "Test", + templates = listOf( + DeepLinkTemplate(uri = "myapp://a"), + DeepLinkTemplate(uri = "myapp://b") + ) + ) + assertEquals(2, scenario.totalLaunchCount) + } + + @Test + fun `totalLaunchCount should multiply templates by variable combinations`() { + val scenario = TestScenario( + id = "1", + name = "Test", + templates = listOf( + DeepLinkTemplate(uri = "myapp://product/{id}") + ), + variables = mapOf("id" to listOf("1", "2", "3")) + ) + assertEquals(3, scenario.totalLaunchCount) + } + + @Test + fun `totalLaunchCount should calculate cartesian product for multiple variables`() { + val scenario = TestScenario( + id = "1", + name = "Test", + templates = listOf( + DeepLinkTemplate(uri = "myapp://product/{id}/{color}") + ), + variables = mapOf( + "id" to listOf("1", "2"), + "color" to listOf("red", "blue", "green") + ) + ) + // 1 template * (2 * 3) combinations = 6 + assertEquals(6, scenario.totalLaunchCount) + } + + @Test + fun `DeepLinkTemplate hasPlaceholders should detect placeholders`() { + assertTrue(DeepLinkTemplate(uri = "myapp://product/{id}").hasPlaceholders) + assertTrue(DeepLinkTemplate(uri = "myapp://{category}/{id}").hasPlaceholders) + } + + @Test + fun `DeepLinkTemplate hasPlaceholders should return false without placeholders`() { + assertFalse(DeepLinkTemplate(uri = "myapp://home").hasPlaceholders) + assertFalse(DeepLinkTemplate(uri = "https://example.com/path").hasPlaceholders) + } + + @Test + fun `DeepLinkTemplate placeholderNames should extract all placeholder names`() { + val template = DeepLinkTemplate(uri = "myapp://product/{id}/variant/{color}") + assertEquals(setOf("id", "color"), template.placeholderNames) + } + + @Test + fun `DeepLinkTemplate placeholderNames should return empty set for no placeholders`() { + val template = DeepLinkTemplate(uri = "myapp://home") + assertTrue(template.placeholderNames.isEmpty()) + } + + @Test + fun `BatchTestResult should calculate success metrics correctly`() { + val result = BatchTestResult( + scenarioName = "Test", + results = listOf( + LaunchResult.Success(uri = "a", resolvedActivity = "A", timeTakenMs = 100), + LaunchResult.Success(uri = "b", resolvedActivity = "B", timeTakenMs = 200), + LaunchResult.Error(uri = "c", errorMessage = "fail", timeTakenMs = 50) + ), + totalTimeMs = 1000 + ) + + assertEquals(3, result.totalCount) + assertEquals(2, result.successCount) + assertEquals(1, result.failureCount) + assertEquals(2f / 3f, result.successRate, 0.001f) + } + + @Test + fun `BatchTestResult should handle empty results`() { + val result = BatchTestResult( + scenarioName = "Empty", + results = emptyList(), + totalTimeMs = 0 + ) + + assertEquals(0, result.totalCount) + assertEquals(0, result.successCount) + assertEquals(0, result.failureCount) + assertEquals(0f, result.successRate) + } + + @Test + fun `LaunchResult isSuccess should return correct values`() { + val success = LaunchResult.Success(uri = "a", resolvedActivity = "A", timeTakenMs = 100) + val error = LaunchResult.Error(uri = "b", errorMessage = "fail", timeTakenMs = 50) + + assertTrue(success.isSuccess) + assertFalse(error.isSuccess) + } + + @Test + fun `LaunchResult uri should be accessible from sealed class`() { + val success: LaunchResult = LaunchResult.Success(uri = "myapp://a", resolvedActivity = "A", timeTakenMs = 100) + val error: LaunchResult = LaunchResult.Error(uri = "myapp://b", errorMessage = "fail", timeTakenMs = 50) + + assertEquals("myapp://a", success.uri) + assertEquals("myapp://b", error.uri) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCaseTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCaseTest.kt new file mode 100644 index 0000000..8312c5f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExecuteBatchTestUseCaseTest.kt @@ -0,0 +1,90 @@ +package com.manjee.linkops.domain.usecase.batchtest + +import com.manjee.linkops.domain.model.LaunchResult +import com.manjee.linkops.domain.model.TestScenario +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ExecuteBatchTestUseCaseTest { + + private val fakeRepository = FakeBatchTestRepository() + private val useCase = ExecuteBatchTestUseCase(fakeRepository) + + @Test + fun `invoke should emit results for all URIs`() = runTest { + val scenario = TestScenario( + id = "test", + name = "Test", + delayBetweenLaunchesMs = 0 + ) + val uris = listOf("myapp://a", "myapp://b", "myapp://c") + + val results = useCase("device-123", scenario, uris).toList() + + assertEquals(3, results.size) + results.forEach { result -> + assertIs(result) + } + } + + @Test + fun `invoke should emit error results for failed launches`() = runTest { + fakeRepository.launchResults["myapp://fail"] = Result.success( + LaunchResult.Error( + uri = "myapp://fail", + errorMessage = "Activity not found", + timeTakenMs = 50 + ) + ) + + val scenario = TestScenario( + id = "test", + name = "Test", + delayBetweenLaunchesMs = 0 + ) + val uris = listOf("myapp://ok", "myapp://fail") + + val results = useCase("device-123", scenario, uris).toList() + + assertEquals(2, results.size) + assertIs(results[0]) + assertIs(results[1]) + assertEquals("Activity not found", (results[1] as LaunchResult.Error).errorMessage) + } + + @Test + fun `invoke should handle repository exceptions gracefully`() = runTest { + fakeRepository.launchResults["myapp://error"] = + Result.failure(RuntimeException("ADB crashed")) + + val scenario = TestScenario( + id = "test", + name = "Test", + delayBetweenLaunchesMs = 0 + ) + val uris = listOf("myapp://error") + + val results = useCase("device-123", scenario, uris).toList() + + assertEquals(1, results.size) + assertIs(results[0]) + assertTrue((results[0] as LaunchResult.Error).errorMessage.contains("ADB crashed")) + } + + @Test + fun `invoke should return empty flow for empty URI list`() = runTest { + val scenario = TestScenario( + id = "test", + name = "Test", + delayBetweenLaunchesMs = 0 + ) + + val results = useCase("device-123", scenario, emptyList()).toList() + + assertTrue(results.isEmpty()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportImportScenarioUseCaseTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportImportScenarioUseCaseTest.kt new file mode 100644 index 0000000..4f13d95 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/ExportImportScenarioUseCaseTest.kt @@ -0,0 +1,61 @@ +package com.manjee.linkops.domain.usecase.batchtest + +import com.manjee.linkops.domain.model.DeepLinkTemplate +import com.manjee.linkops.domain.model.TestScenario +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ExportImportScenarioUseCaseTest { + + private val fakeRepository = FakeBatchTestRepository() + private val exportUseCase = ExportScenarioUseCase(fakeRepository) + private val importUseCase = ImportScenarioUseCase(fakeRepository) + + @Test + fun `export should return success with JSON`() = runTest { + fakeRepository.exportResult = Result.success("""{"id":"1","name":"Test"}""") + + val scenario = TestScenario(id = "1", name = "Test") + val result = exportUseCase(scenario) + + assertTrue(result.isSuccess) + assertEquals("""{"id":"1","name":"Test"}""", result.getOrNull()) + } + + @Test + fun `export should return failure on error`() = runTest { + fakeRepository.exportResult = Result.failure(RuntimeException("Serialization error")) + + val scenario = TestScenario(id = "1", name = "Test") + val result = exportUseCase(scenario) + + assertTrue(result.isFailure) + } + + @Test + fun `import should return success with scenario`() = runTest { + val expected = TestScenario( + id = "imported", + name = "Imported Scenario", + templates = listOf(DeepLinkTemplate(uri = "myapp://test")) + ) + fakeRepository.importResult = Result.success(expected) + + val result = importUseCase("""{"id":"imported"}""") + + assertTrue(result.isSuccess) + assertEquals("imported", result.getOrNull()?.id) + assertEquals("Imported Scenario", result.getOrNull()?.name) + } + + @Test + fun `import should return failure for invalid JSON`() = runTest { + fakeRepository.importResult = Result.failure(RuntimeException("Invalid JSON")) + + val result = importUseCase("not valid json") + + assertTrue(result.isFailure) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/FakeBatchTestRepository.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/FakeBatchTestRepository.kt new file mode 100644 index 0000000..f9d8044 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/batchtest/FakeBatchTestRepository.kt @@ -0,0 +1,38 @@ +package com.manjee.linkops.domain.usecase.batchtest + +import com.manjee.linkops.domain.model.LaunchResult +import com.manjee.linkops.domain.model.TestScenario +import com.manjee.linkops.domain.repository.BatchTestRepository + +/** + * Fake implementation of BatchTestRepository for testing + */ +class FakeBatchTestRepository : BatchTestRepository { + + var launchResults = mutableMapOf>() + var exportResult: Result = Result.success("{}") + var importResult: Result = Result.success( + TestScenario(id = "test", name = "Test") + ) + + override suspend fun launchDeepLink( + deviceSerial: String, + uri: String + ): Result { + return launchResults[uri] ?: Result.success( + LaunchResult.Success( + uri = uri, + resolvedActivity = "com.test/.TestActivity", + timeTakenMs = 100 + ) + ) + } + + override suspend fun exportScenario(scenario: TestScenario): Result { + return exportResult + } + + override suspend fun importScenario(json: String): Result { + return importResult + } +}