package cc.unitmesh.devins.ui.compose.agent.codereview import cc.unitmesh.codegraph.CodeGraphFactory import cc.unitmesh.codegraph.model.CodeElementType import cc.unitmesh.codegraph.model.CodeNode import cc.unitmesh.codegraph.parser.Language import cc.unitmesh.devins.workspace.Workspace /** * Interface for finding test files related to source files */ interface TestFinder { /** * Find test files related to the given source file * * @param sourceFile Path to the source file (relative to project root) * @param workspace Project workspace * @return List of related test files */ suspend fun findTestFiles(sourceFile: String, workspace: Workspace): List /** * Check if this finder is applicable for the given language */ fun isApplicable(language: String?): Boolean } /** * Factory to create appropriate TestFinder based on language */ object TestFinderFactory { private val finders = listOf( ComposeKmpTestFinder(), // Highest priority for Compose KMP projects JavaKotlinTestFinder(), PythonTestFinder(), JavaScriptTestFinder() ) /** * Get test finder for the given language */ fun getTestFinder(language: String?): TestFinder? { return finders.firstOrNull { it.isApplicable(language) } } /** * Find tests using all applicable finders */ suspend fun findTests(sourceFile: String, language: String?, workspace: Workspace): List { val finder = getTestFinder(language) ?: return emptyList() return finder.findTestFiles(sourceFile, workspace) } } /** * Base implementation for test finders */ abstract class BaseTestFinder : TestFinder { /** * Parse test file and extract test structure using mpp-codegraph */ protected suspend fun parseTestFile( filePath: String, language: Language, workspace: Workspace ): TestFileInfo { return try { val content = workspace.fileSystem.readFile(filePath) if (content == null) { return TestFileInfo( filePath = filePath, language = language.name, testCases = emptyList(), exists = false ) } val parser = CodeGraphFactory.createParser() val nodes = parser.parseNodes(content, filePath, language) // Build tree structure from nodes val testCases = buildTestTree(nodes) TestFileInfo( filePath = filePath, language = language.name, testCases = testCases, exists = true ) } catch (e: Exception) { TestFileInfo( filePath = filePath, language = language.name, testCases = emptyList(), exists = true, parseError = e.message ) } } /** * Build hierarchical test tree from flat list of code nodes * Filters out invalid nodes with empty or "unknown" names */ private fun buildTestTree(nodes: List): List { // Helper function to check if a name is valid fun isValidName(name: String): Boolean { return name.isNotBlank() && name.lowercase() != "unknown" && !name.startsWith("<") && !name.startsWith("$") } // Find all test classes (top-level and nested) - filter out invalid ones val classNodes = nodes.filter { (it.type == CodeElementType.CLASS || it.type == CodeElementType.INTERFACE) && isValidName(it.name) } // Find all test methods/functions - filter out invalid ones val methodNodes = nodes.filter { (it.type == CodeElementType.METHOD || it.type == CodeElementType.FUNCTION) && isValidName(it.name) } // If no classes found, return methods as top-level nodes (for languages like Python) if (classNodes.isEmpty()) { return methodNodes.map { method -> TestCaseNode( name = method.name, type = TestNodeType.METHOD, startLine = method.startLine, endLine = method.endLine ) } } // Build tree: classes with their methods as children return classNodes.mapNotNull { classNode -> val classMethods = methodNodes.filter { method -> // Check if method belongs to this class based on line ranges method.startLine >= classNode.startLine && method.endLine <= classNode.endLine } val childMethods = classMethods.map { method -> TestCaseNode( name = method.name, type = TestNodeType.METHOD, startLine = method.startLine, endLine = method.endLine ) } // Only include class nodes that have valid children or are valid themselves if (childMethods.isNotEmpty() || isValidName(classNode.name)) { TestCaseNode( name = classNode.name, type = TestNodeType.CLASS, children = childMethods, startLine = classNode.startLine, endLine = classNode.endLine, qualifiedName = classNode.qualifiedName ) } else { null } } } /** * Convert relative path from src to test * E.g., "src/main/java/com/example/Foo.java" -> "src/test/java/com/example/FooTest.java" */ protected fun convertPathToTest(sourcePath: String, mainDir: String, testDir: String, suffix: String): String { return sourcePath.replace(mainDir, testDir).replace(Regex("\\.(\\w+)$")) { match -> suffix + match.value } } } /** * Test finder for Java and Kotlin projects */ class JavaKotlinTestFinder : BaseTestFinder() { override fun isApplicable(language: String?): Boolean { return language?.lowercase() in listOf("java", "kotlin") } override suspend fun findTestFiles(sourceFile: String, workspace: Workspace): List { val results = mutableListOf() // Determine language val language = when { sourceFile.endsWith(".java") -> Language.JAVA sourceFile.endsWith(".kt") -> Language.KOTLIN else -> return emptyList() } // Try different test path conventions val testPaths = listOf( // Standard Maven/Gradle: src/main/java -> src/test/java convertPathToTest(sourceFile, "/src/main/java/", "/src/test/java/", "Test"), convertPathToTest(sourceFile, "/src/main/kotlin/", "/src/test/kotlin/", "Test"), // Alternative: append Test to filename convertPathToTest(sourceFile, "/main/", "/test/", "Test"), // Kotlin multiplatform: commonMain -> commonTest, jvmMain -> jvmTest convertPathToTest(sourceFile, "/commonMain/", "/commonTest/", "Test"), convertPathToTest(sourceFile, "/jvmMain/", "/jvmTest/", "Test"), ) for (testPath in testPaths) { val testFile = parseTestFile(testPath, language, workspace) if (testFile.exists) { results.add(testFile) } } return results } } /** * Test finder for Python projects */ class PythonTestFinder : BaseTestFinder() { override fun isApplicable(language: String?): Boolean { return language?.lowercase() == "python" } override suspend fun findTestFiles(sourceFile: String, workspace: Workspace): List { val results = mutableListOf() // Python test conventions: // 1. test_foo.py for foo.py // 2. foo_test.py for foo.py // 3. tests/test_foo.py for src/foo.py val baseName = sourceFile.substringAfterLast("/").removeSuffix(".py") val dir = sourceFile.substringBeforeLast("/", "") val testPaths = listOf( "$dir/test_$baseName.py", "$dir/${baseName}_test.py", "tests/test_$baseName.py", "test/test_$baseName.py", ) for (testPath in testPaths) { val testFile = parseTestFile(testPath, Language.PYTHON, workspace) if (testFile.exists) { results.add(testFile) } } return results } } /** * Test finder for JavaScript/TypeScript projects */ class JavaScriptTestFinder : BaseTestFinder() { override fun isApplicable(language: String?): Boolean { return language?.lowercase() in listOf("javascript", "typescript", "jsx", "tsx") } override suspend fun findTestFiles(sourceFile: String, workspace: Workspace): List { val results = mutableListOf() // Determine language and extension val (language, extension) = when { sourceFile.endsWith(".ts") -> Language.TYPESCRIPT to ".ts" sourceFile.endsWith(".tsx") -> Language.TYPESCRIPT to ".tsx" sourceFile.endsWith(".jsx") -> Language.JAVASCRIPT to ".jsx" else -> Language.JAVASCRIPT to ".js" } // JavaScript test conventions: // 1. foo.test.js/foo.spec.js // 2. __tests__/foo.js // 3. foo.test.ts for TypeScript val baseName = sourceFile.substringAfterLast("/").removeSuffix(extension) val dir = sourceFile.substringBeforeLast("/", "") val testPaths = listOf( "$dir/$baseName.test$extension", "$dir/$baseName.spec$extension", "$dir/__tests__/$baseName$extension", "$dir/__tests__/$baseName.test$extension", ) for (testPath in testPaths) { val testFile = parseTestFile(testPath, language, workspace) if (testFile.exists) { results.add(testFile) } } return results } }