diff --git a/modules/test-generator/src/main/kotlin/dev/diff2test/android/testgenerator/AiTestGenerator.kt b/modules/test-generator/src/main/kotlin/dev/diff2test/android/testgenerator/AiTestGenerator.kt index 4512b4b..cedde76 100644 --- a/modules/test-generator/src/main/kotlin/dev/diff2test/android/testgenerator/AiTestGenerator.kt +++ b/modules/test-generator/src/main/kotlin/dev/diff2test/android/testgenerator/AiTestGenerator.kt @@ -1073,6 +1073,8 @@ private fun elapsedFailure(startedAt: Long, message: String, error: Exceptio throw IllegalStateException(message, error) } +private val DIRECT_SINGLETON_ACCESS_PATTERN = Regex("""\b[A-Z][A-Za-z0-9_]*\.[a-z][A-Za-z0-9_]*""") + private fun maybeBypassAi( plan: TestPlan, context: TestContext, @@ -1080,8 +1082,10 @@ private fun maybeBypassAi( fallback: TestGenerator, ): GeneratedTestBundle? { val usesDirectSingleton = analysis.publicMethods.any { method -> - Regex("""\b[A-Z][A-Za-z0-9_]*\.[a-z][A-Za-z0-9_]*""").containsMatchIn(method.body.orEmpty()) - } + DIRECT_SINGLETON_ACCESS_PATTERN.containsMatchIn(method.body.orEmpty()) + } || runCatching { + Files.exists(analysis.filePath) && DIRECT_SINGLETON_ACCESS_PATTERN.containsMatchIn(Files.readString(analysis.filePath)) + }.getOrDefault(false) if (context.styleGuide.coroutineEntryPoint == "runTest" || !usesDirectSingleton) { return null } diff --git a/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt b/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt index f60d9b8..cadad38 100644 --- a/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt +++ b/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt @@ -589,6 +589,66 @@ class OpenAiResponsesTestGeneratorTest { assertContains(bundle.warnings.joinToString("\n"), "AI generation was skipped") } + @Test + fun `bypasses ai when changed methods miss singleton access but source file contains it`() { + val sourceFile = Files.createTempFile("nutrition-chat-viewmodel", ".kt") + Files.writeString( + sourceFile, + """ + package net.ifmain.androiddummy.chatbot.ui + + class NutritionChatViewModel { + fun onInputChange(input: String) {} + + fun sendMessage() { + ChatbotService.api.sendMessage(request) + } + } + """.trimIndent(), + ) + + val analysis = ViewModelAnalysis( + className = "NutritionChatViewModel", + packageName = "net.ifmain.androiddummy.chatbot.ui", + filePath = sourceFile, + publicMethods = listOf( + dev.diff2test.android.core.TargetMethod( + name = "onInputChange", + signature = "fun onInputChange(input: String)", + body = "_uiState.update { it }", + mutatesState = true, + ), + ), + ) + val generator = ChatCompletionsTestGenerator( + config = ChatCompletionsConfig( + apiKey = "sk-local", + model = "qwen3-coder-next-mlx", + baseUrl = "http://127.0.0.1:12345/v1", + ), + httpClient = fakeHttpClient(statusCode = 500, body = "should not be called"), + ) + + val bundle = generator.generate( + plan = TestPlan( + targetClass = "NutritionChatViewModel", + targetMethods = listOf("onInputChange"), + testType = TestType.LOCAL_UNIT, + scenarios = emptyList(), + requiredFakes = emptyList(), + assertions = emptyList(), + riskLevel = RiskLevel.LOW, + ), + context = TestContext( + moduleName = "app", + styleGuide = StyleGuide(assertionStyle = "junit4", coroutineEntryPoint = "unavailable"), + ), + analysis = analysis, + ) + + assertContains(bundle.warnings.joinToString("\n"), "AI generation was skipped") + } + private fun findRepoRoot(): Path { var current: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize()