diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7aa3a..2afc1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,7 @@ ## 1.0.2 - 2024-10-12 - Bugfix: change plugin name introduce a bug in the code. + +## 1.0.3 - 2024-11-14 +- Add unit test annotation generation action. +- Add unit test annotation inspection. diff --git a/build.gradle.kts b/build.gradle.kts index 6c3311e..b2400e3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.changelog) // Gradle Changelog Plugin alias(libs.plugins.qodana) // Gradle Qodana Plugin alias(libs.plugins.kover) // Gradle Kover Plugin + alias(libs.plugins.serialize) // Gradle Serializer Plugin } group = providers.gradleProperty("pluginGroup").get() @@ -29,7 +30,10 @@ intellij { type.set(providers.gradleProperty("platformType")) // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file. - plugins.set(providers.gradleProperty("platformBundledPlugins").map { it.split(',').map(String::trim).filter(String::isNotEmpty) }) + plugins.set(providers.gradleProperty("platformBundledPlugins") + .map { it.split(',').map(String::trim).filter(String::isNotEmpty) } + .map { it + listOf("git4idea") } + ) } // Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog @@ -41,6 +45,7 @@ dependencies { implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.18.0") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.18.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") // Use latest version } // Configure Gradle Changelog Plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin @@ -124,6 +129,6 @@ tasks { // The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel - channels = providers.gradleProperty("pluginVersion").map { listOf(it.split('-').getOrElse(1) { "default" }.split('.').first()) } + channels = providers.gradleProperty("pluginVersion").map { listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) } } } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index b4e07b0..d3235ba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.github.jaksonlin.pitestintellij pluginName = pitest-gradle pluginRepositoryUrl = https://github.com/jaksonlin/pitest-gradle # SemVer format -> https://semver.org -pluginVersion = 1.0.2 +pluginVersion = 1.0.3 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 222 @@ -18,7 +18,7 @@ platformVersion = 2022.2 # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP platformPlugins = # Example: platformBundledPlugins = com.intellij.java -platformBundledPlugins = com.intellij.java, com.intellij.gradle +platformBundledPlugins = com.intellij.java, com.intellij.gradle,com.intellij.java,git4idea # Gradle Releases -> https://github.com/gradle/gradle/releases gradleVersion = 8.9 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7782a23..6293ac1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,3 +20,4 @@ gradleIntelliJPlugin = { id = "org.jetbrains.intellij", version.ref = "gradleInt kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } +serialize = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/GenerateAnnotationCommandAction.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/GenerateAnnotationCommandAction.kt new file mode 100644 index 0000000..c0cb62c --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/GenerateAnnotationCommandAction.kt @@ -0,0 +1,33 @@ +package com.github.jaksonlin.pitestintellij.actions + +import com.github.jaksonlin.pitestintellij.commands.unittestannotations.GenerateAnnotationCommand +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiMethod +import com.intellij.psi.util.PsiTreeUtil + + +class GenerateAnnotationCommandAction : AnAction() { + + override fun actionPerformed(e: AnActionEvent) { + val psiMethodInfo = findMethodAtCaret(e) ?: return + val context = CaseCheckContext.create(psiMethodInfo.first, psiMethodInfo.second) + GenerateAnnotationCommand(e.project!!, context).execute() + } + + private fun findMethodAtCaret(e: AnActionEvent): Pair? { + val project = e.project ?: return null + val editor = e.dataContext.getData(CommonDataKeys.EDITOR) ?: return null + val caret = e.dataContext.getData(CommonDataKeys.CARET) ?: return null + val elementAtCaret = PsiDocumentManager.getInstance(project) + .getPsiFile(editor.document)?.findElementAt(caret.offset) ?: return null + val method = PsiTreeUtil.getParentOfType(elementAtCaret, PsiMethod::class.java) ?: return null + val containingClass = PsiTreeUtil.getParentOfType(method, PsiClass::class.java) ?: return null + return method to containingClass + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/RunCaseAnnoationCheckAction.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/RunCaseAnnoationCheckAction.kt new file mode 100644 index 0000000..91540c5 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/RunCaseAnnoationCheckAction.kt @@ -0,0 +1,32 @@ +package com.github.jaksonlin.pitestintellij.actions + +import com.github.jaksonlin.pitestintellij.commands.unittestannotations.CheckAnnotationCommand +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiMethod +import com.intellij.psi.util.PsiTreeUtil + +class RunCaseAnnoationCheckAction : AnAction() { + + override fun actionPerformed(e: AnActionEvent) { + val psiMethodInfo = findMethodAtCaret(e) ?: return + val context = CaseCheckContext.create(psiMethodInfo.first, psiMethodInfo.second) + CheckAnnotationCommand(e.project!!, context).execute() + } + + private fun findMethodAtCaret(e: AnActionEvent): Pair? { + val project = e.project ?: return null + val editor = e.dataContext.getData(CommonDataKeys.EDITOR) ?: return null + val caret = e.dataContext.getData(CommonDataKeys.CARET) ?: return null + val elementAtCaret = PsiDocumentManager.getInstance(project) + .getPsiFile(editor.document)?.findElementAt(caret.offset) ?: return null + val method = PsiTreeUtil.getParentOfType(elementAtCaret, PsiMethod::class.java) ?: return null + val containingClass = PsiTreeUtil.getParentOfType(method, PsiClass::class.java) ?: return null + return method to containingClass + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/RunTestFileAnnoationCheckAction.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/RunTestFileAnnoationCheckAction.kt new file mode 100644 index 0000000..2da3ca0 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/actions/RunTestFileAnnoationCheckAction.kt @@ -0,0 +1,43 @@ +package com.github.jaksonlin.pitestintellij.actions + +import com.github.jaksonlin.pitestintellij.commands.unittestannotations.CheckAnnotationCommand +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.psi.JavaRecursiveElementVisitor +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMethod +import com.intellij.psi.util.PsiTreeUtil + +class RunTestFileAnnoationCheckAction: AnAction() { + + override fun actionPerformed(e: AnActionEvent) { + batchCheckAnnotation(e) + } + + private fun batchCheckAnnotation(e: AnActionEvent){ + val psiFile = e.dataContext.getData(CommonDataKeys.PSI_FILE) + val psiJavaFile = psiFile as PsiJavaFile + + psiJavaFile.accept(object : JavaRecursiveElementVisitor() { + override fun visitMethod(method: PsiMethod) { + super.visitMethod(method) + // inspect the method annotations + val annotations = method.annotations + for (annotation in annotations) { + // inspect the annotation + val annotationName = annotation.qualifiedName + if (annotationName!=null && // to support junit 4 & 5, do not use regexp, as it will also match some beforeTest/afterTest annotations + (annotationName == "org.junit.Test" || annotationName == "org.junit.jupiter.api.Test" || annotationName == "Test")) { + val psiClass = PsiTreeUtil.getParentOfType(method, PsiClass::class.java) ?: return + val context = CaseCheckContext.create(method, psiClass) + CheckAnnotationCommand(e.project!!, context).execute() + break + } + } + } + }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationFieldConfig.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationFieldConfig.kt new file mode 100644 index 0000000..acc839c --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationFieldConfig.kt @@ -0,0 +1,33 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import kotlinx.serialization.Contextual +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +@Serializable +sealed class DefaultValue { + @Serializable + @SerialName("StringValue") + data class StringValue(val value: String) : DefaultValue() + + @Serializable + @SerialName("StringListValue") + data class StringListValue(val value: List) : DefaultValue() + + @Serializable + @SerialName("NullValue") + object NullValue : DefaultValue() +} + + +@Serializable +data class AnnotationFieldConfig( + val name: String, + val type: AnnotationFieldType, + val required: Boolean = false, + val defaultValue: DefaultValue = DefaultValue.NullValue, + val validation: FieldValidation? = null, + val valueProvider: ValueProvider? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationFieldType.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationFieldType.kt new file mode 100644 index 0000000..a90cce8 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationFieldType.kt @@ -0,0 +1,9 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import kotlinx.serialization.Serializable + +@Serializable +enum class AnnotationFieldType { + STRING, + STRING_LIST +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationParser.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationParser.kt new file mode 100644 index 0000000..24633e4 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationParser.kt @@ -0,0 +1,52 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import com.github.jaksonlin.pitestintellij.context.UnittestCase + + +class AnnotationParser(private val schema: AnnotationSchema) { + private val validator = AnnotationValidator(schema) + + fun parseAnnotation(annotationValues: Map): UnittestCase { + when (val result = validator.validate(annotationValues)) { + is AnnotationValidator.ValidationResult.Valid -> { + val parsedValues = schema.fields.associate { field -> + if (field.required) { + if (!annotationValues.containsKey(field.name)) { + throw IllegalArgumentException("Missing required field: ${field.name}") + } + if (annotationValues[field.name] == null) { + throw IllegalArgumentException("Required field cannot be null: ${field.name}") + } + } else { + if (!annotationValues.containsKey(field.name) || annotationValues[field.name] == null) { + return@associate field.name to field.defaultValue + } + } + val rawValue = annotationValues[field.name] + field.name to convertValue(rawValue, field) + } + return UnittestCase(parsedValues) + } + is AnnotationValidator.ValidationResult.Invalid -> { + throw IllegalArgumentException( + "Invalid annotation values:\n${result.errors.joinToString("\n")}" + ) + } + } + } + + private fun convertValue(value: Any?, field: AnnotationFieldConfig): Any? { + if (value == null) { + return when (val defaultValue = field.defaultValue) { + is DefaultValue.StringValue -> defaultValue.value + is DefaultValue.StringListValue -> defaultValue.value + DefaultValue.NullValue -> null + } + } + + return when (field.type) { + AnnotationFieldType.STRING -> value as? String ?: field.defaultValue + AnnotationFieldType.STRING_LIST -> (value as? List<*>)?.mapNotNull { it as? String } ?: emptyList() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationSchema.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationSchema.kt new file mode 100644 index 0000000..d41e519 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationSchema.kt @@ -0,0 +1,255 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class AnnotationSchema( + val annotationClassName: String, + val fields: List +) { + companion object { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + val DEFAULT_SCHEMA = """ + { + "annotationClassName": "UnittestCaseInfo", + "fields": [ + { + "name": "author", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "FIRST_CREATOR_AUTHOR" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "title", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "METHOD_NAME_BASED" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "targetClass", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "CLASS_NAME" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "targetMethod", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "METHOD_NAME" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "lastUpdateTime", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "LAST_MODIFIER_TIME", + "format": "yyyy-MM-dd HH:mm:ss" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "lastUpdateAuthor", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "LAST_MODIFIER_AUTHOR" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "methodSignature", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "METHOD_SIGNATURE" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "testPoints", + "type": "STRING_LIST", + "required": true, + "valueProvider": { + "type": "FIXED_VALUE", + "value": ["Functionality"] + }, + "defaultValue": { + "type": "StringListValue", + "value": [] + }, + "validation": { + "validValues": [ + "BoundaryValue", + "NonEmpty", + "ErrorHandling", + "InputValidation", + "PositiveScenario", + "NegativeScenario", + "EdgeCase", + "Functionality", + "BusinessLogicValidation", + "BusinessInputOutput", + "SideEffects", + "StateTransition", + "BusinessCalculation", + "Security", + "Performance" + ], + "allowCustomValues": true, + "minLength": 1, + "mode": "CONTAINS", + "allowEmpty": false + } + }, + { + "name": "status", + "type": "STRING", + "required": false, + "valueProvider": { + "type": "FIXED_VALUE", + "value": "TODO" + }, + "defaultValue": { + "type": "StringValue", + "value": "TODO" + }, + "validation": { + "validValues": [ + "TODO", + "IN_PROGRESS", + "DONE", + "DEPRECATED", + "BROKEN" + ], + "allowCustomValues": true, + "mode": "CONTAINS", + "allowEmpty": false + } + }, + { + "name": "description", + "type": "STRING", + "required": true, + "valueProvider": { + "type": "METHOD_NAME_BASED" + }, + "validation": { + "allowEmpty": false + }, + "defaultValue": { + "type": "StringValue", + "value": "" + } + }, + { + "name": "tags", + "type": "STRING_LIST", + "required": false, + "defaultValue": { + "type": "StringListValue", + "value": [] + }, + "validation": { + "minLength": 1 + } + }, + { + "name": "relatedRequirements", + "type": "STRING_LIST", + "required": false, + "defaultValue": { + "type": "StringListValue", + "value": [] + }, + "validation": { + "minLength": 1 + } + }, + { + "name": "relatedTestCases", + "type": "STRING_LIST", + "required": false, + "defaultValue": { + "type": "StringListValue", + "value": [] + }, + "validation": { + "minLength": 1 + } + }, + { + "name": "relatedDefects", + "type": "STRING_LIST", + "required": false, + "defaultValue": { + "type": "StringListValue", + "value": [] + }, + "validation": { + "minLength": 1 + } + } + ] + } + """.trimIndent() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationValidator.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationValidator.kt new file mode 100644 index 0000000..e843d4c --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationValidator.kt @@ -0,0 +1,171 @@ +package com.github.jaksonlin.pitestintellij.annotations + +class AnnotationValidator(private val schema: AnnotationSchema) { + sealed class ValidationResult { + object Valid : ValidationResult() + data class Invalid(val errors: List) : ValidationResult() + } + + fun validate(annotationValues: Map): ValidationResult { + val errors = mutableListOf() + + // Check required fields + schema.fields + .filter { it.required } + .forEach { field -> + if (!annotationValues.containsKey(field.name) || annotationValues[field.name] == null) { + errors.add("Missing required field: ${field.name}") + } + } + + // Validate field types + annotationValues.forEach { (name, value) -> + schema.fields.find { it.name == name }?.let { field -> + validateField(field, value)?.let { error -> + errors.add(error) + } + } + } + + return if (errors.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(errors) + } + + private fun validateField(field: AnnotationFieldConfig, value: Any?): String? { + if (value == null) return null + + // First validate the type + val typeError = validateType(field, value) + if (typeError != null) return typeError + + // Then validate non-empty if required + val emptyError = validateNonEmpty(field, value) + if (emptyError != null) return emptyError + + // Then validate against the validation rules if they exist + field.validation?.let { validation -> + when (value) { + is String -> { + validateStringValue(field.name, value, validation)?.let { return it } + } + is List<*> -> { + // Validate list content + validateListContent(field.name, value, validation)?.let { return it } + + // Validate list length + validateListLength(field.name, value, validation)?.let { return it } + } + + else -> { + // Should not happen + return "Unsupported field type for ${field.name}: ${value::class.simpleName}" + } + } + } + + return null + } + + private fun validateNonEmpty(field: AnnotationFieldConfig, value: Any): String? { + if (field.validation?.allowEmpty == false) { + when (value) { + is String -> { + if (value.isBlank()) { + return "Field ${field.name} cannot be empty" + } + } + is List<*> -> { + if (value.isEmpty()) { + return "Field ${field.name} cannot be empty" + } + } + } + } + return null + } + + private fun validateType(field: AnnotationFieldConfig, value: Any): String? { + return when (field.type) { + AnnotationFieldType.STRING -> { + if (value !is String) "Field ${field.name} must be a String" + else null + } + AnnotationFieldType.STRING_LIST -> { + when (value) { + is List<*> -> { + if (value.any { it !is String }) { + "Field ${field.name} must be a list of Strings" + } else null + } + else -> "Field ${field.name} must be a List" + } + } + } + } + + private fun validateStringValue( + fieldName: String, + value: String, + validation: FieldValidation + ): String? { + if (!validation.allowEmpty && value.isBlank()) { + return "Field $fieldName cannot be empty" + } + if (validation.validValues.isNotEmpty() && !validation.allowCustomValues) { + val isValid = when (validation.mode) { + ValidationMode.EXACT -> validation.validValues.contains(value) + ValidationMode.CONTAINS -> validation.validValues.any { candidate -> + value.contains(candidate, ignoreCase = true) + } + } + if (!isValid) { + return "Invalid value for $fieldName: $value. Valid values are: ${validation.validValues.joinToString()}" + } + } + return null + } + + private fun validateListContent( + fieldName: String, + value: List<*>, + validation: FieldValidation + ): String? { + if (validation.validValues.isNotEmpty() && !validation.allowCustomValues) { + val invalidValues = value.filterIsInstance() + .filter { item -> + when (validation.mode) { + ValidationMode.EXACT -> !validation.validValues.contains(item) + ValidationMode.CONTAINS -> !validation.validValues.any { candidate -> + item.contains(candidate, ignoreCase = true) + } + } + } + if (invalidValues.isNotEmpty()) { + return "Invalid values for $fieldName: ${invalidValues.joinToString()}. Valid values are: ${validation.validValues.joinToString()}" + } + } + return null + } + + private fun validateListLength( + fieldName: String, + value: List<*>, + validation: FieldValidation + ): String? { + validation.allowEmpty.let { allowEmpty -> + if (!allowEmpty && value.isEmpty()) { + return "Field $fieldName cannot be empty" + } + } + validation.minLength?.let { min -> + if (value.size < min) { + return "$fieldName must contain at least $min element${if (min > 1) "s" else ""}" + } + } + validation.maxLength?.let { max -> + if (value.size > max) { + return "$fieldName cannot contain more than $max element${if (max > 1) "s" else ""}" + } + } + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/FieldValidation.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/FieldValidation.kt new file mode 100644 index 0000000..3b6bd5a --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/FieldValidation.kt @@ -0,0 +1,13 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import kotlinx.serialization.Serializable + +@Serializable +data class FieldValidation( + val validValues: List = emptyList(), + val allowCustomValues: Boolean = true, + val minLength: Int? = null, // Added for list length validation + val maxLength: Int? = null, // Optional: add max length constraint + val mode: ValidationMode = ValidationMode.EXACT, + val allowEmpty: Boolean = true // New field to control empty string validation +) diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/UnittestAnnotationConfig.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/UnittestAnnotationConfig.kt new file mode 100644 index 0000000..cc2ef03 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/UnittestAnnotationConfig.kt @@ -0,0 +1,14 @@ +package com.github.jaksonlin.pitestintellij.annotations + +data class UnittestAnnotationConfig( + val authorField: String = "author", + val titleField: String = "title", + val targetClassField: String = "targetClass", + val targetMethodField: String = "targetMethod", + val testPointsField: String = "testPoints", + val statusField: String = "status", + val descriptionField: String = "description", + val tagsField: String = "tags", + val relatedRequirementsField: String = "relatedRequirements", + val relatedDefectsField: String = "relatedDefects" +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/ValidationMode.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/ValidationMode.kt new file mode 100644 index 0000000..2279e56 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/ValidationMode.kt @@ -0,0 +1,9 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import kotlinx.serialization.Serializable + +@Serializable +enum class ValidationMode { + EXACT, // Exact string match + CONTAINS, // String contains check +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/ValueProvider.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/ValueProvider.kt new file mode 100644 index 0000000..e9dbc35 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/annotations/ValueProvider.kt @@ -0,0 +1,26 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class ValueProvider( + val type: ValueProviderType, + val format: String? = null, + val value: JsonElement? = null +) + +@Serializable +enum class ValueProviderType { + GIT_AUTHOR, + FIRST_CREATOR_AUTHOR, + FIRST_CREATOR_TIME, + LAST_MODIFIER_AUTHOR, + LAST_MODIFIER_TIME, + CURRENT_DATE, + METHOD_NAME_BASED, + FIXED_VALUE, + CLASS_NAME, + METHOD_NAME, + METHOD_SIGNATURE +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/BuildPitestCommandCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/BuildPitestCommandCommand.kt similarity index 95% rename from src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/BuildPitestCommandCommand.kt rename to src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/BuildPitestCommandCommand.kt index 9b2d271..7cf2e9c 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/BuildPitestCommandCommand.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/BuildPitestCommandCommand.kt @@ -1,6 +1,5 @@ -package com.github.jaksonlin.pitestintellij.commands +package com.github.jaksonlin.pitestintellij.commands.pitest -import PitestCommand import com.github.jaksonlin.pitestintellij.context.PitestContext import com.intellij.openapi.project.Project @@ -41,6 +40,7 @@ class BuildPitestCommandCommand (project: Project, context: PitestContext) : Pit "2.0", "--mutators", "STRONGER", + "--skipFailingTests", ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/HandlePitestResultCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/HandlePitestResultCommand.kt similarity index 95% rename from src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/HandlePitestResultCommand.kt rename to src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/HandlePitestResultCommand.kt index a00b743..de19bbe 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/HandlePitestResultCommand.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/HandlePitestResultCommand.kt @@ -1,6 +1,5 @@ -package com.github.jaksonlin.pitestintellij.commands +package com.github.jaksonlin.pitestintellij.commands.pitest -import PitestCommand import com.github.jaksonlin.pitestintellij.context.PitestContext import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/PitestCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/PitestCommand.kt similarity index 97% rename from src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/PitestCommand.kt rename to src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/PitestCommand.kt index bf3efdd..ea7d341 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/PitestCommand.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/PitestCommand.kt @@ -1,3 +1,5 @@ +package com.github.jaksonlin.pitestintellij.commands.pitest + import com.github.jaksonlin.pitestintellij.commands.CommandCancellationException import com.github.jaksonlin.pitestintellij.ui.PitestOutputDialog import com.intellij.openapi.application.ApplicationManager @@ -5,7 +7,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import com.github.jaksonlin.pitestintellij.context.PitestContext import com.github.jaksonlin.pitestintellij.context.dumpPitestContext -import com.github.jaksonlin.pitestintellij.services.PitestService import com.github.jaksonlin.pitestintellij.services.RunHistoryManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/PrepareEnvironmentCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/PrepareEnvironmentCommand.kt similarity index 91% rename from src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/PrepareEnvironmentCommand.kt rename to src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/PrepareEnvironmentCommand.kt index a7aad0f..60f4575 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/PrepareEnvironmentCommand.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/PrepareEnvironmentCommand.kt @@ -1,6 +1,6 @@ -package com.github.jaksonlin.pitestintellij.commands +package com.github.jaksonlin.pitestintellij.commands.pitest -import PitestCommand +import com.github.jaksonlin.pitestintellij.commands.CommandCancellationException import com.github.jaksonlin.pitestintellij.context.PitestContext import com.github.jaksonlin.pitestintellij.util.FileUtils import com.github.jaksonlin.pitestintellij.util.GradleUtils @@ -9,6 +9,7 @@ import com.intellij.openapi.application.PathManager import com.intellij.openapi.application.ReadAction import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project +import com.intellij.openapi.project.rootManager import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.ui.Messages @@ -112,25 +113,27 @@ class PrepareEnvironmentCommand(project: Project, context: PitestContext) : Pite context.targetClassFilePath = targetClassInfo.file.normalize().toString().replace("\\", "/") } - private fun prepareReportDirectory(testVirtualFile: VirtualFile, className: String){ + private fun prepareReportDirectory(testVirtualFile: VirtualFile, className: String) { // prepare the report directory val parentModulePath = ReadAction.compute { - val projectModule = ProjectRootManager.getInstance(project).fileIndex.getModuleForFile(testVirtualFile) - if (projectModule == null) { - showError("Cannot find module for test file") - throw IllegalStateException("Cannot find module for test file") + ?: throw IllegalStateException("Cannot find module for test file") + + val modulePath = GradleUtils.getUpperModulePath(project, projectModule) + if (modulePath.isEmpty()) { + // If no parent module found, use the current module's path + projectModule.rootManager.contentRoots.firstOrNull()?.path + ?: throw IllegalStateException("Cannot find module root path") + } else { + modulePath } - - GradleUtils.getUpperModulePath(project, projectModule) } + context.reportDirectory = Paths.get(parentModulePath, "build", "reports", "pitest", className).toString() File(context.reportDirectory!!).mkdirs() - } - private fun collectClassPathFileForPitest(reportDirectory:String, targetPackageName:String, resourceDirectories: List?){ val classPathFileContent = ReadAction.compute { val classpath = GradleUtils.getCompilationOutputPaths(project) diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/RunPitestCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/RunPitestCommand.kt similarity index 91% rename from src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/RunPitestCommand.kt rename to src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/RunPitestCommand.kt index ff6dc0e..645e2a7 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/RunPitestCommand.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/RunPitestCommand.kt @@ -1,6 +1,5 @@ -package com.github.jaksonlin.pitestintellij.commands +package com.github.jaksonlin.pitestintellij.commands.pitest -import PitestCommand import com.github.jaksonlin.pitestintellij.context.PitestContext import com.github.jaksonlin.pitestintellij.util.ProcessExecutor import com.intellij.openapi.diagnostic.thisLogger diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/StoreHistoryCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/StoreHistoryCommand.kt similarity index 67% rename from src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/StoreHistoryCommand.kt rename to src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/StoreHistoryCommand.kt index 857ff19..997604a 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/StoreHistoryCommand.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/pitest/StoreHistoryCommand.kt @@ -1,8 +1,6 @@ -package com.github.jaksonlin.pitestintellij.commands +package com.github.jaksonlin.pitestintellij.commands.pitest -import PitestCommand import com.github.jaksonlin.pitestintellij.context.PitestContext -import com.github.jaksonlin.pitestintellij.services.RunHistoryManager import com.intellij.openapi.project.Project class StoreHistoryCommand (project: Project, context: PitestContext) : PitestCommand(project, context) { diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/CheckAnnotationCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/CheckAnnotationCommand.kt new file mode 100644 index 0000000..2302b14 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/CheckAnnotationCommand.kt @@ -0,0 +1,73 @@ +package com.github.jaksonlin.pitestintellij.commands.unittestannotations + +import com.github.jaksonlin.pitestintellij.annotations.AnnotationFieldType +import com.github.jaksonlin.pitestintellij.annotations.AnnotationSchema +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.github.jaksonlin.pitestintellij.context.UnittestCase +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiAnnotation +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiMethod +import com.intellij.psi.util.PsiTreeUtil + +class CheckAnnotationCommand(project: Project, context: CaseCheckContext):UnittestCaseCheckCommand(project, context) { + override fun execute() { + val annotation = findTargetAnnotation(context.psiMethod, context.schema) + if (annotation == null) { + showNoAnnotationMessage(project, context.schema.annotationClassName) + return + } + processAnnotation(annotation) + return + } + + + + private fun processAnnotation( + annotation: PsiAnnotation + ) { + try { + val testCase = parseUnittestCaseFromAnnotations(annotation) + val message = formatTestCaseMessage(testCase, context.schema) + showSuccessMessage(project, message) + } catch (e: Exception) { + showErrorMessage(project, e.message ?: "Unknown error") + } + } + + private fun extractMethodBodyComments(psiMethod: PsiMethod): String { + val stepComments = mutableListOf() + val assertComments = mutableListOf() + + PsiTreeUtil.findChildrenOfType(psiMethod, PsiComment::class.java).forEach { + if (it.text.contains("step", ignoreCase = true)) { + stepComments.add(it.text) + } + if (it.text.contains("assert", ignoreCase = true)) { + assertComments.add(it.text) + } + } + // format as : test_step_1: step_comment, remove the leading // + return stepComments.mapIndexed { index, comment -> "test_step_${index + 1}: ${comment.substring(2)}" }.joinToString("\n") + "\n" + assertComments.mapIndexed { index, comment -> "test_assert_${index + 1}: ${comment.substring(2)}" }.joinToString("\n") + } + + + private fun formatTestCaseMessage( + testCase: UnittestCase, + schema: AnnotationSchema + ): String = buildString { + appendLine("Test Case Details:") + schema.fields.forEach { field -> + append(field.name) + append(": ") + when (field.type) { + AnnotationFieldType.STRING -> + appendLine(testCase.getString(field.name)) + AnnotationFieldType.STRING_LIST -> + appendLine(testCase.getStringList(field.name).joinToString(", ")) + } + } + appendLine(extractMethodBodyComments(context.psiMethod)) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/CheckMethodDataCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/CheckMethodDataCommand.kt new file mode 100644 index 0000000..cdb3434 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/CheckMethodDataCommand.kt @@ -0,0 +1,30 @@ +package com.github.jaksonlin.pitestintellij.commands.unittestannotations + +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiMethod +import com.intellij.psi.util.PsiTreeUtil + +class CheckMethodDataCommand(project: Project, context: CaseCheckContext):UnittestCaseCheckCommand(project, context) { + override fun execute() { + val annotation = findTargetAnnotation(context.psiMethod, context.schema) + if (annotation == null) { + showNoAnnotationMessage(project, context.schema.annotationClassName) + return + } + val comments = extractCommentsFromMethodBody(context.psiMethod) + if (comments.isEmpty()) { + showErrorMessage(project, "No comments found in the method body") + return + } + } + + private fun extractCommentsFromMethodBody(psiMethod: PsiMethod): List { + val comments = mutableListOf() + PsiTreeUtil.findChildrenOfType(psiMethod, PsiComment::class.java).forEach { + comments.add(it.text) + } + return comments + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/GenerateAnnotationCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/GenerateAnnotationCommand.kt new file mode 100644 index 0000000..2bb2644 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/GenerateAnnotationCommand.kt @@ -0,0 +1,307 @@ +package com.github.jaksonlin.pitestintellij.commands.unittestannotations + +import com.github.jaksonlin.pitestintellij.annotations.AnnotationFieldType +import com.github.jaksonlin.pitestintellij.annotations.AnnotationSchema +import com.github.jaksonlin.pitestintellij.annotations.DefaultValue +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.github.jaksonlin.pitestintellij.services.AnnotationConfigService +import com.github.jaksonlin.pitestintellij.services.ValueProviderService +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.psi.* +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.ui.CheckboxTree +import com.intellij.ui.CheckboxTreeListener +import com.intellij.ui.CheckedTreeNode +import com.intellij.ui.components.JBScrollPane +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.FlowLayout +import javax.swing.* + +class GenerateAnnotationCommand(project: Project, context: CaseCheckContext):UnittestCaseCheckCommand(project, context) { + private val psiElementFactory: PsiElementFactory = JavaPsiFacade.getInstance(project).elementFactory + private val configService = service() + private val valueProviderService = project.service() + override fun execute() { + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Generating Annotations") { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + + if (context.psiClass.methods.isEmpty()) { + ApplicationManager.getApplication().invokeLater { + showNoMethodMessage(project) + } + return + } + generateAnnotationForSelectedMethod() + } + }) + } + + private fun generateAnnotationForSelectedMethod() { + val psiClass = context.psiClass + val testMethods = psiClass.methods.filter { canAddAnnotation(it) } + + if (testMethods.isEmpty()) { + ApplicationManager.getApplication().invokeLater { + showNoTestMethodCanAddMessage(project) + } + return + } + + // UI interaction needs to happen on EDT + ApplicationManager.getApplication().invokeLater { + val methodNames = testMethods.map { it.name }.toTypedArray() + val selected = BooleanArray(methodNames.size) { true } + + val dialog = object : DialogWrapper(project) { + private val tree: CheckboxTree = createMethodSelectionTree(methodNames, selected) + private val buttonPanel = JPanel(FlowLayout(FlowLayout.LEFT)) + + init { + init() + title = "Select Test Methods" + createButtons() + } + + private fun createButtons() { + val checkAllButton = JButton("Check All").apply { + addActionListener { + setAllNodesChecked(true) + } + } + + val uncheckAllButton = JButton("Uncheck All").apply { + addActionListener { + setAllNodesChecked(false) + } + } + + buttonPanel.add(checkAllButton) + buttonPanel.add(uncheckAllButton) + } + + private fun setAllNodesChecked(checked: Boolean) { + val root = tree.model.root as CheckedTreeNode + setNodeAndChildrenChecked(root, checked) + tree.repaint() + } + + private fun setNodeAndChildrenChecked(node: CheckedTreeNode, checked: Boolean) { + node.isChecked = checked + for (i in 0 until node.childCount) { + val child = node.getChildAt(i) as? CheckedTreeNode ?: continue + setNodeAndChildrenChecked(child, checked) + + // Update the selected array if this is a leaf node (method) + if (child.userObject is String) { + val index = methodNames.indexOf(child.userObject as String) + if (index >= 0) { + selected[index] = checked + } + } + } + } + + override fun createCenterPanel(): JComponent { + val panel = JPanel(BorderLayout()) + panel.preferredSize = Dimension(400, 400) + + // Add the button panel at the top + panel.add(buttonPanel, BorderLayout.NORTH) + + // Add the tree with scroll pane in the center + val treePanel = JPanel(BorderLayout()) + treePanel.border = BorderFactory.createEmptyBorder(5, 5, 5, 5) + treePanel.add(JBScrollPane(tree), BorderLayout.CENTER) + panel.add(treePanel, BorderLayout.CENTER) + + return panel + } + } + + if (dialog.showAndGet()) { + // Back to background thread for processing + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Applying Annotations") { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + testMethods.filterIndexed { index, _ -> selected[index] } + .forEach { method -> + generateAnnotationForSingleMethod(method) + } + } + }) + } + } + } + + private fun createMethodSelectionTree(methodNames: Array, selected: BooleanArray): CheckboxTree { + val root = CheckedTreeNode("Test Methods") + + methodNames.forEachIndexed { index, name -> + val node = CheckedTreeNode(name) + node.isChecked = selected[index] + root.add(node) + } + + return CheckboxTree( + object : CheckboxTree.CheckboxTreeCellRenderer() { + override fun customizeRenderer( + tree: JTree, + value: Any, + selected: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ) { + if (value is CheckedTreeNode) { + when (value.userObject) { + is String -> textRenderer.append(value.userObject as String) + else -> textRenderer.append(value.userObject.toString()) + } + } + } + }, + root + ).apply { + addCheckboxTreeListener(object : CheckboxTreeListener { + override fun nodeStateChanged(node: CheckedTreeNode) { + if (node.userObject is String) { + val index = methodNames.indexOf(node.userObject as String) + if (index >= 0) { + selected[index] = node.isChecked + } + } + } + }) + isRootVisible = false // Hide the root node + showsRootHandles = true // Show handles for the root's children + } + } + + + + private fun isMethodJunitTestMethod(psiMethod: PsiMethod): Boolean { + val annotations = psiMethod.annotations + return annotations.any { it.qualifiedName == "org.junit.Test" || it.qualifiedName == "org.junit.jupiter.api.Test" || it.qualifiedName == "Test"} + } + + private fun canAddAnnotation(psiMethod: PsiMethod): Boolean { + return ApplicationManager.getApplication().runReadAction { + isMethodJunitTestMethod(psiMethod) && findTargetAnnotation(psiMethod, context.schema) == null + } + } + + private fun generateAnnotationForSingleMethod(psiMethod: PsiMethod) { + ApplicationManager.getApplication().executeOnPooledThread { + generateAnnotation(psiMethod, context.schema) + } + } + + protected fun generateAnnotation(psiMethod: PsiMethod, schema: AnnotationSchema) { + // First compute the annotation text in a read action + val annotationText = ReadAction.compute { + val newContext = context.copy(psiMethod = psiMethod) + buildAnnotationStr(schema, newContext) + } + + // Then use the computed text in the write action + WriteCommandAction.runWriteCommandAction(project) { + // Ensure document is committed before PSI modifications + val document = PsiDocumentManager.getInstance(project).getDocument(psiMethod.containingFile) + if (document != null) { + PsiDocumentManager.getInstance(project).commitDocument(document) + } + + if (configService.isAutoImport()) { + addImportIfNeeded(psiMethod, schema.annotationClassName) + } + val annotation = buildAnnotation(annotationText) + psiMethod.modifierList.addAfter(annotation, null) + } + } + + private val LOG = Logger.getInstance(GenerateAnnotationCommand::class.java) + + private fun addImportIfNeeded(psiMethod: PsiMethod, annotationClassName: String) { + val file = psiMethod.containingFile as? PsiJavaFile ?: return + LOG.info("Processing file: ${file.name}") + + val importList = file.importList ?: return + LOG.info("Current imports: ${importList.importStatements.joinToString { it.qualifiedName ?: "null" }}") + + val qualifiedName = "${configService.getAnnotationPackage()}.$annotationClassName" + LOG.info("Trying to add import for: $qualifiedName") + + // Only add if not already imported + if (importList.importStatements.none { it.qualifiedName == qualifiedName }) { + LOG.info("Import not found, attempting to add") + + val project = file.project + val facade = JavaPsiFacade.getInstance(project) + val scope = GlobalSearchScope.allScope(project) + + LOG.info("Searching for class in global scope") + val psiClass = facade.findClass(qualifiedName, scope) + LOG.info("Found class: ${psiClass != null}") + + try { + val importStatement = psiElementFactory.createImportStatement( + psiClass ?: return + ) + LOG.info("Created import statement: ${importStatement.text}") + + importList.add(importStatement) + LOG.info("Import added successfully") + } catch (e: Exception) { + LOG.error("Failed to add import", e) + } + } else { + LOG.info("Import already exists") + } + } + + // we should do this in a read action + private fun buildAnnotationStr(schema: AnnotationSchema, buildAnnotationContext: CaseCheckContext) : String { + val annotationText = buildString { + append("@${schema.annotationClassName}(\n") + schema.fields.filter{ it.required }.forEachIndexed { index, field -> + if (index > 0) append(",\n") + append(" ${field.name} = ") + + // Use value provider if available + val value = field.valueProvider?.let { provider -> + valueProviderService.provideValue(provider, buildAnnotationContext) + } ?: field.defaultValue + + when (field.type) { + AnnotationFieldType.STRING -> append("\"$value\"") + AnnotationFieldType.STRING_LIST -> { + val list = (value as? List<*>)?.joinToString(", ") { + val str = it.toString() + if (str.startsWith("\"") && str.endsWith("\"")) str else "\"$str\"" + } + append("{$list}") + } + } + } + append("\n)") + } + return annotationText + } + + private fun buildAnnotation(annotationText:String): PsiAnnotation { + // Build the annotation text with default values + return psiElementFactory.createAnnotationFromText(annotationText, null) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/UnittestCaseCheckCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/UnittestCaseCheckCommand.kt new file mode 100644 index 0000000..47127bf --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/UnittestCaseCheckCommand.kt @@ -0,0 +1,127 @@ +package com.github.jaksonlin.pitestintellij.commands.unittestannotations + +import com.github.jaksonlin.pitestintellij.annotations.AnnotationSchema +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.github.jaksonlin.pitestintellij.context.UnittestCase +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.psi.PsiAnnotation +import com.intellij.psi.PsiArrayInitializerMemberValue +import com.intellij.psi.PsiLiteralExpression +import com.intellij.psi.PsiMethod + +abstract class UnittestCaseCheckCommand(protected val project: Project, protected val context: CaseCheckContext) { + abstract fun execute() + fun showSuccessMessage(project: Project, message: String) { + Messages.showMessageDialog( + project, + message, + "Test Annotation Details", + Messages.getInformationIcon() + ) + } + + fun showErrorMessage(project: Project, message: String) { + Messages.showMessageDialog( + project, + "Error parsing annotation: $message", + "Test File Action", + Messages.getErrorIcon() + ) + } + + fun showNoAnnotationMessage(project: Project, annotationName: String) { + Messages.showMessageDialog( + project, + "No $annotationName annotation found on this method", + "Test File Action", + Messages.getWarningIcon() + ) + } + + fun showNotJunitTestMethodMessage(project: Project) { + Messages.showMessageDialog( + project, + "This method is not a JUnit test method", + "Annotation Generation Action", + Messages.getWarningIcon() + ) + } + + fun showNoMethodMessage(project: Project) { + Messages.showMessageDialog( + project, + "No methods found in this class", + "Annotation Generation Action", + Messages.getWarningIcon() + ) + } + + fun showNoTestMethodCanAddMessage(project: Project) { + Messages.showMessageDialog( + project, + "No test methods found in the class that can add annotation.", + "No Test Methods Can Add Annotation", + Messages.getInformationIcon() + ) + } + + fun showAnnotationAlreadyExistMessage(project: Project, annotationName: String) { + Messages.showMessageDialog( + project, + "$annotationName already exist on this method", + "Annotation Generation Action", + Messages.getWarningIcon() + ) + } + + fun findTargetAnnotation( + psiMethod: PsiMethod, + schema: AnnotationSchema + ): PsiAnnotation? { + return psiMethod.annotations.find { annotation -> + annotation.qualifiedName?.contains(schema.annotationClassName) == true + } + } + + fun extractStringArrayValue(annotation: PsiAnnotation, attributeName: String): List { + val attributeValue = annotation.findAttributeValue(attributeName) + return if (attributeValue is PsiArrayInitializerMemberValue) { + // retrieve the array element into the tags + //attributeValue.initializers.map { it.text } + attributeValue.initializers.mapNotNull { (it as? PsiLiteralExpression)?.value as? String } + } else { + emptyList() + } + } + + fun showValidationErrors(project: Project, errors: List) { + val message = buildString { + appendLine("Annotation validation failed:") + errors.forEach { error -> + appendLine("- $error") + } + } + Messages.showMessageDialog( + project, + message, + "Validation Errors", + Messages.getErrorIcon() + ) + } + + fun parseAnnotationValues(annotation: PsiAnnotation): Map { + return annotation.parameterList.attributes.associate { attr -> + attr.attributeName to when (val value = attr.value) { + is PsiArrayInitializerMemberValue -> + value.initializers.map { it.text.trim('"') } + else -> value?.text?.trim('"') ?: "" + } + } + } + + fun parseUnittestCaseFromAnnotations(annotation: PsiAnnotation):UnittestCase{ + val annotationValues = parseAnnotationValues(annotation) + return context.parser.parseAnnotation(annotationValues) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/UnittestFileInspectorCommand.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/UnittestFileInspectorCommand.kt new file mode 100644 index 0000000..5e2121d --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/commands/unittestannotations/UnittestFileInspectorCommand.kt @@ -0,0 +1,46 @@ +package com.github.jaksonlin.pitestintellij.commands.unittestannotations + +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiMethod +import com.intellij.psi.util.PsiTreeUtil + +class UnittestFileInspectorCommand(private val holder: ProblemsHolder, project: Project, context: CaseCheckContext):UnittestCaseCheckCommand(project, context) { + override fun execute() { + checkAnnotationSchema(context.psiMethod) + checkIfCommentHasStepAndAssert(context.psiMethod) + } + + private fun checkAnnotationSchema(psiMethod:PsiMethod) { + try { + val annotation = findTargetAnnotation(psiMethod, context.schema) + if (annotation == null) { + holder.registerProblem(context.psiMethod, "No unittest case management annotation found", ProblemHighlightType.WARNING) + return + } + val testCase = parseUnittestCaseFromAnnotations(annotation) + } catch (e: Exception) { + holder.registerProblem(context.psiMethod, e.message ?: "Unknown error", ProblemHighlightType.ERROR) + } + } + + private fun checkIfCommentHasStepAndAssert(psiMethod: PsiMethod) { + var hasStep = false + var hasAssert = false + PsiTreeUtil.findChildrenOfType(psiMethod, PsiComment::class.java).forEach { + //comments.add(it.text) + if (it.text.contains("step", ignoreCase = true)) { + hasStep = true + } + if (it.text.contains("assert", ignoreCase = true)) { + hasAssert = true + } + } + if (!hasStep || !hasAssert) { + holder.registerProblem(psiMethod, "Method should contains both step and assert comment", ProblemHighlightType.ERROR) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/completion/AnnotationCompletionContributor.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/completion/AnnotationCompletionContributor.kt new file mode 100644 index 0000000..34138cc --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/completion/AnnotationCompletionContributor.kt @@ -0,0 +1,112 @@ +package com.github.jaksonlin.pitestintellij.completion + +import com.github.jaksonlin.pitestintellij.annotations.DefaultValue +import com.github.jaksonlin.pitestintellij.annotations.ValidationMode +import com.intellij.codeInsight.completion.* +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.patterns.PlatformPatterns +import com.intellij.psi.* +import com.intellij.util.ProcessingContext +import com.github.jaksonlin.pitestintellij.services.AnnotationConfigService +import com.github.jaksonlin.pitestintellij.ui.CustomAnnotationCompletionLookupElement +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.lang.java.JavaLanguage +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.patterns.ElementPattern +import com.intellij.psi.util.PsiTreeUtil + +class AnnotationCompletionContributor : CompletionContributor() { + private val LOG = Logger.getInstance(AnnotationCompletionContributor::class.java) + + init { + LOG.info("Initializing AnnotationCompletionContributor") + extend( + CompletionType.BASIC, + PlatformPatterns.psiElement() + .inside(PsiAnnotation::class.java) // Inside an annotation + .withLanguage(JavaLanguage.INSTANCE), // Ensure it's Java + object : CompletionProvider() { + override fun addCompletions( + parameters: CompletionParameters, + context: ProcessingContext, + result: CompletionResultSet + ) { + // Find the containing annotation and name-value pair + val annotation = PsiTreeUtil.getParentOfType(parameters.position, PsiAnnotation::class.java) + if (annotation == null){ + return + } + LOG.info("Found annotation: ${annotation.qualifiedName}") + val nameValuePair = PsiTreeUtil.getParentOfType(parameters.position, PsiNameValuePair::class.java) + if (nameValuePair == null){ + return + } + + + LOG.info("Found attribute: ${nameValuePair.name}") + + // Rest of your completion logic... + if (nameValuePair == null || annotation == null) { + LOG.info("Required PSI elements not found") + return + } + val project = parameters.position.project + val configService = service() + val schema = configService.getSchema() + LOG.info("Schema annotation class: ${schema.annotationClassName}") + LOG.info("Actual annotation class: ${annotation.qualifiedName}") + + // Check if this is our target annotation + if (annotation.qualifiedName?.endsWith(schema.annotationClassName) != true) { + LOG.info("Annotation mismatch: ${annotation.qualifiedName} not the same as ${schema.annotationClassName}") + return + } + + // Find matching field in schema + val fieldName = nameValuePair.name + LOG.info("Field name: $fieldName") + + val field = schema.fields.find { it.name == fieldName } + if (field == null) { + LOG.info("Field not found in schema") + return + } + + // Add completion items + field.validation?.validValues?.forEach { value -> + val isDefault = when (field.defaultValue) { + is DefaultValue.StringValue -> field.defaultValue.value == value + is DefaultValue.StringListValue -> value in field.defaultValue.value + else -> false + } + + val element = CustomAnnotationCompletionLookupElement( + value = value, + fieldType = field.type, + isDefaultValue = isDefault + ) + var properitizedValue = 100.0 + when { + // Highest priority for exact matches + result.prefixMatcher.prefixMatches(value) -> properitizedValue = 100.0 + // High priority for default values + isDefault -> properitizedValue = 90.0 + // Medium priority for validated values + field.validation?.validValues?.contains(value) == true -> properitizedValue = 80.0 + // Lower priority for other suggestions + else -> properitizedValue = 70.0 + } + val prioritized = PrioritizedLookupElement.withPriority(element,properitizedValue) + LOG.info("Adding element: $value with priority ${properitizedValue}") + + result.addElement(prioritized) + } + + } + + + } + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/CaseCheckContext.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/CaseCheckContext.kt new file mode 100644 index 0000000..bdba324 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/CaseCheckContext.kt @@ -0,0 +1,32 @@ +package com.github.jaksonlin.pitestintellij.context + +import com.github.jaksonlin.pitestintellij.services.AnnotationConfigService +import com.github.jaksonlin.pitestintellij.actions.RunCaseAnnoationCheckAction +import com.github.jaksonlin.pitestintellij.annotations.AnnotationParser +import com.github.jaksonlin.pitestintellij.annotations.AnnotationSchema +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiMethod + +data class CaseCheckContext( + val psiClass: PsiClass, + val psiMethod: PsiMethod, + val schema: AnnotationSchema, + val parser: AnnotationParser +) { + companion object { + fun create(psiMethod: PsiMethod, psiClass: PsiClass): CaseCheckContext { + val configService = service() + val schema = configService.getSchema() + return CaseCheckContext( + psiClass = psiClass, + psiMethod = psiMethod, + schema = schema, + parser = AnnotationParser(schema) + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/TestPoints.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/TestPoints.kt new file mode 100644 index 0000000..81e2eac --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/TestPoints.kt @@ -0,0 +1,32 @@ +package com.github.jaksonlin.pitestintellij.context + + +object TestPoints { + const val NONE_EMPTY_CHECK = "NonEmptyCheck" + const val BOUNDARY_VALUE_CHECK = "BoundaryValueCheck" + const val ERROR_HANDLING = "ErrorHandling" + const val INPUT_VALIDATION = "InputValidation" + const val POSITIVE_BRANCH = "PositiveBranch" + const val NEGATIVE_BRANCH = "NegativeBranch" + const val FUNCTIONALITY_CHECK = "FunctionalityCheck" + const val BUSINESS_LOGIC_VALIDATION = "BusinessLogicValidation" + const val BUSINESS_INPUT_CHECK = "BusinessInputOutputCheck" + const val SIDE_EFFECT_CHECK = "SideEffectsCheck" + const val STATE_TRANSITION_CHECK = "StateTransitionCheck" + const val BUSINESS_CALCULATION_CHECK = "BusinessCalculationCheck" + + val builtinTestPoints = listOf( + NONE_EMPTY_CHECK, + BOUNDARY_VALUE_CHECK, + ERROR_HANDLING, + INPUT_VALIDATION, + POSITIVE_BRANCH, + NEGATIVE_BRANCH, + FUNCTIONALITY_CHECK, + BUSINESS_LOGIC_VALIDATION, + BUSINESS_INPUT_CHECK, + SIDE_EFFECT_CHECK, + STATE_TRANSITION_CHECK, + BUSINESS_CALCULATION_CHECK + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCase.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCase.kt new file mode 100644 index 0000000..72a0a9a --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCase.kt @@ -0,0 +1,9 @@ +package com.github.jaksonlin.pitestintellij.context + +data class UnittestCase( + val values: Map +) { + fun getString(key: String): String = values[key] as? String ?: "" + fun getStringList(key: String): List = values[key] as? List ?: emptyList() + fun getStatus(key: String): String = values[key] as? String ?: "TODO" +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCaseInfoContext.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCaseInfoContext.kt new file mode 100644 index 0000000..b323aee --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCaseInfoContext.kt @@ -0,0 +1,20 @@ +package com.github.jaksonlin.pitestintellij.context + +import com.github.jaksonlin.pitestintellij.annotations.UnittestAnnotationConfig + +data class UnittestCaseInfoContext( + val annotationValues: Map, + private val config: UnittestAnnotationConfig = UnittestAnnotationConfig() +) { + val author: String get() = annotationValues[config.authorField] as? String ?: "" + val title: String get() = annotationValues[config.titleField] as? String ?: "" + val targetClass: String get() = annotationValues[config.targetClassField] as? String ?: "" + val targetMethod: String get() = annotationValues[config.targetMethodField] as? String ?: "" + val testPoints: List get() = (annotationValues[config.testPointsField] as? List<*>)?.mapNotNull { it as? String } ?: emptyList() + var status: UnittestCaseStatus = UnittestCaseStatus.TODO + var description: String = annotationValues[config.descriptionField] as? String ?: "" + var tags: List = (annotationValues[config.tagsField] as? List<*>)?.mapNotNull { it as? String } ?: emptyList() + var relatedRequirements: List = (annotationValues[config.relatedRequirementsField] as? List<*>)?.mapNotNull { it as? String } ?: emptyList() + var relatedDefects: List = (annotationValues[config.relatedDefectsField] as? List<*>)?.mapNotNull { it as? String } ?: emptyList() +} + diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCaseStatus.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCaseStatus.kt new file mode 100644 index 0000000..6663865 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestCaseStatus.kt @@ -0,0 +1,5 @@ +package com.github.jaksonlin.pitestintellij.context + +enum class UnittestCaseStatus { + TODO, DONE, BROKEN, DEPRECATED +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestMethodContext.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestMethodContext.kt new file mode 100644 index 0000000..eb30fef --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/context/UnittestMethodContext.kt @@ -0,0 +1,8 @@ +package com.github.jaksonlin.pitestintellij.context + +data class UnittestMethodContext ( + + val methodName: String, + val comments: List + +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/inspectors/UnittestInspector.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/inspectors/UnittestInspector.kt new file mode 100644 index 0000000..3bc5375 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/inspectors/UnittestInspector.kt @@ -0,0 +1,65 @@ +package com.github.jaksonlin.pitestintellij.inspectors + +import com.github.jaksonlin.pitestintellij.MyBundle +import com.github.jaksonlin.pitestintellij.commands.unittestannotations.UnittestFileInspectorCommand +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.intellij.codeInspection.* +import com.intellij.psi.* +import com.intellij.psi.util.PsiTreeUtil +import java.util.concurrent.ConcurrentHashMap + +class UnittestInspector : AbstractBaseJavaLocalInspectionTool() { + override fun getGroupDisplayName(): String = MyBundle.message("inspection.group.name") + override fun getDisplayName(): String = MyBundle.message("inspection.display.name") + override fun getShortName(): String = "UnittestCaseAnnotationInspection" + // Cache test annotation qualified names for faster lookup + private val testAnnotations = setOf( + "org.junit.Test", + "org.junit.jupiter.api.Test", + "Test" + ) + + private val testClassAnnotations = setOf( + "org.junit.runner.RunWith", + "org.junit.jupiter.api.TestInstance", + "org.junit.platform.suite.api.Suite" + ) + + // Cache test class status to avoid repeated checks + private val testClassCache = ConcurrentHashMap() + + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + val project = holder.project + return object : JavaElementVisitor() { + override fun visitMethod(psiMethod: PsiMethod) { + // Quick check for test annotation before doing anything else + if (!hasTestAnnotation(psiMethod)) { + return + } + + // Check containing class using cache + val containingClass = psiMethod.containingClass ?: return + val qualifiedName = containingClass.qualifiedName ?: return + // get the psiclass for the containing class + val psiClass = PsiTreeUtil.getParentOfType(psiMethod, PsiClass::class.java) ?: return + val context = CaseCheckContext.create(psiMethod, psiClass) + UnittestFileInspectorCommand(holder, project, context).execute() + } + + private fun hasTestAnnotation(psiMethod: PsiMethod): Boolean { + return psiMethod.annotations.any { annotation -> + annotation.qualifiedName in testAnnotations + } + } + + + } + } + + override fun getID(): String = "UnittestCaseAnnotationInspection" + + // Clear cache when plugin is unloaded + fun clearCache() { + testClassCache.clear() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/TestPointProcessor.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/TestPointProcessor.kt new file mode 100644 index 0000000..4ccf655 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/TestPointProcessor.kt @@ -0,0 +1,24 @@ +package com.github.jaksonlin.pitestintellij.processor + +import com.github.jaksonlin.pitestintellij.context.TestPoints +import com.intellij.ide.util.PropertiesComponent + + +class TestPointProcessor { + companion object { + private var validTestPoints = TestPoints.builtinTestPoints + + fun isValidTestPoint(testPoint: String): Boolean = validTestPoints.contains(testPoint) + + fun loadCustomizedTestPoints() { + val propertiesComponent = PropertiesComponent.getInstance() + val defaultTestPoints = propertiesComponent.getValue( + "defaultTestPoints", + TestPoints.builtinTestPoints.joinToString(",") + ) + validTestPoints = defaultTestPoints.split(",") + } + + fun getValidTestPoints(): List = validTestPoints + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/UnittestCaseInfoProcessor.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/UnittestCaseInfoProcessor.kt new file mode 100644 index 0000000..2ccf483 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/UnittestCaseInfoProcessor.kt @@ -0,0 +1,52 @@ +package com.github.jaksonlin.pitestintellij.processor + +import com.github.jaksonlin.pitestintellij.context.UnittestCaseInfoContext +import com.github.jaksonlin.pitestintellij.context.UnittestCaseStatus +import com.intellij.psi.PsiAnnotation +import com.intellij.psi.PsiArrayInitializerMemberValue +import com.intellij.psi.PsiLiteralExpression +import com.intellij.psi.PsiReferenceExpression + +class UnittestCaseInfoProcessor { +// companion object { +// fun fromPsiAnnotation(annotation: PsiAnnotation): UnittestCaseInfoContext { +// val annotationResult = UnittestCaseInfoContext( +// author = annotation.findAttributeValue("author")?.text ?: "", +// title = annotation.findAttributeValue("title")?.text ?: "", +// targetClass = annotation.findAttributeValue("targetClass")?.text ?: "", +// targetMethod = annotation.findAttributeValue("targetMethod")?.text ?: "", +// testPoints = extractStringArrayValue(annotation, "testPoints") +// ) +// +// // process tags +// annotationResult.tags = extractStringArrayValue(annotation, "tags") +// // process relatedRequirement +// annotationResult.relatedRequirements = extractStringArrayValue(annotation, "relatedRequirements") +// // process relatedDefect +// annotationResult.relatedDefects = extractStringArrayValue(annotation, "relatedDefects") +// +// // process status enum +// val statusValue = annotation.findAttributeValue("status") +// if (statusValue is PsiReferenceExpression) { +// annotationResult.status = UnittestCaseStatus.valueOf(statusValue.text.substringAfterLast(".")) +// } +// +// // process description +// val descriptionValue = annotation.findAttributeValue("description")?.text +// if (descriptionValue != null) { +// annotationResult.description = descriptionValue +// } +// +// return annotationResult +// } +// +// private fun extractStringArrayValue(annotation: PsiAnnotation, attributeName: String): List { +// val attributeValue = annotation.findAttributeValue(attributeName) +// return if (attributeValue is PsiArrayInitializerMemberValue) { +// attributeValue.initializers.mapNotNull { (it as? PsiLiteralExpression)?.value as? String } +// } else { +// emptyList() +// } +// } +// } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/UnittestMethodProcessor.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/UnittestMethodProcessor.kt new file mode 100644 index 0000000..3196023 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/processor/UnittestMethodProcessor.kt @@ -0,0 +1,23 @@ +package com.github.jaksonlin.pitestintellij.processor + +import com.github.jaksonlin.pitestintellij.context.UnittestMethodContext +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiMethod +import com.intellij.psi.util.PsiTreeUtil + +class UnittestMethodProcessor { + companion object { + fun fromPsiMethod(psiMethod: PsiMethod): UnittestMethodContext { + val comments = extractCommentsFromMethodBody(psiMethod) + return UnittestMethodContext(psiMethod.name, comments) + } + + private fun extractCommentsFromMethodBody(psiMethod: PsiMethod): List { + val comments = mutableListOf() + PsiTreeUtil.findChildrenOfType(psiMethod, PsiComment::class.java).forEach { + comments.add(it.text) + } + return comments + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/AnnotationConfigService.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/AnnotationConfigService.kt new file mode 100644 index 0000000..d4a6c51 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/AnnotationConfigService.kt @@ -0,0 +1,66 @@ +package com.github.jaksonlin.pitestintellij.services +import com.github.jaksonlin.pitestintellij.annotations.AnnotationSchema +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.diagnostic.Logger +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Service(Service.Level.APP) +@State( + name = "AnnotationConfig", + storages = [Storage("pitestAnnotationConfig.xml")] +) +class AnnotationConfigService : PersistentStateComponent { + private val LOG = Logger.getInstance(AnnotationConfigService::class.java) + + data class State( + var schemaJson: String = AnnotationSchema.DEFAULT_SCHEMA, + var annotationPackage: String = "com.example.unittest.annotations", + var autoImport: Boolean = true + ) + + private var myState = State() + + override fun getState(): State = myState + + override fun loadState(state: State) { + LOG.info("Loading annotation config: ${state.schemaJson}") + myState = state + } + + fun getSchema(): AnnotationSchema { + return try { + Json.decodeFromString(myState.schemaJson) + } catch (e: Exception) { + Json.decodeFromString(AnnotationSchema.DEFAULT_SCHEMA) + } + } + + fun updateSchema(schema: AnnotationSchema) { + myState.schemaJson = Json.encodeToString(schema) + LOG.info("Updated annotation config: ${myState.schemaJson}") + } + + fun getBuildInSchema(): AnnotationSchema { + return Json.decodeFromString(AnnotationSchema.DEFAULT_SCHEMA) + } + + // New methods for import configuration + fun getAnnotationPackage(): String = myState.annotationPackage + + fun setAnnotationPackage(packageName: String) { + myState.annotationPackage = packageName + LOG.info("Updated annotation package: $packageName") + } + + fun isAutoImport(): Boolean = myState.autoImport + + fun setAutoImport(auto: Boolean) { + myState.autoImport = auto + LOG.info("Updated auto import setting: $auto") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/PitestService.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/PitestService.kt index 586383e..ebaa4f9 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/PitestService.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/PitestService.kt @@ -1,6 +1,7 @@ package com.github.jaksonlin.pitestintellij.services import com.github.jaksonlin.pitestintellij.commands.* +import com.github.jaksonlin.pitestintellij.commands.pitest.* import com.github.jaksonlin.pitestintellij.context.PitestContext import com.github.jaksonlin.pitestintellij.context.dumpPitestContext import com.intellij.openapi.application.ApplicationManager diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/TestClassCacheService.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/TestClassCacheService.kt new file mode 100644 index 0000000..a534cb9 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/TestClassCacheService.kt @@ -0,0 +1,15 @@ +import com.intellij.openapi.components.Service +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.PROJECT) +class TestClassCacheService { + private val testClassCache = ConcurrentHashMap() + + fun isTestClass(qualifiedName: String, compute: () -> Boolean): Boolean { + return testClassCache.getOrPut(qualifiedName, compute) + } + + fun clearCache() { + testClassCache.clear() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/ValueProviderService.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/ValueProviderService.kt new file mode 100644 index 0000000..fbf665c --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/services/ValueProviderService.kt @@ -0,0 +1,180 @@ +package com.github.jaksonlin.pitestintellij.services + +import com.github.jaksonlin.pitestintellij.annotations.ValueProvider +import com.github.jaksonlin.pitestintellij.annotations.ValueProviderType +import com.github.jaksonlin.pitestintellij.context.CaseCheckContext +import com.github.jaksonlin.pitestintellij.util.GitUtil +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.GlobalSearchScope +import java.text.SimpleDateFormat +import java.util.* + +@Service(Service.Level.PROJECT) +class ValueProviderService(private val project: Project) { + fun provideValue(provider: ValueProvider, context: CaseCheckContext): Any? { + return when (provider.type) { + ValueProviderType.GIT_AUTHOR -> getGitAuthor() + ValueProviderType.LAST_MODIFIER_AUTHOR -> getLastModifierAuthor(context.psiMethod) + ValueProviderType.LAST_MODIFIER_TIME -> getLastModifierTime(context.psiMethod) + ValueProviderType.CURRENT_DATE -> getCurrentDate(provider.format) + ValueProviderType.METHOD_NAME_BASED -> generateDescription(context.psiMethod) + ValueProviderType.FIXED_VALUE -> provider.value + ValueProviderType.CLASS_NAME -> guessClassUnderTestClassName(context.psiClass) // return the qualified name for the class under test + ValueProviderType.METHOD_NAME -> guessMethodUnderTestMethodName(context.psiMethod) // return the method name for the method under test + ValueProviderType.METHOD_SIGNATURE -> tryGetMethodUnderTestSignature(context.psiClass, context.psiMethod) + ?: "" + ValueProviderType.FIRST_CREATOR_AUTHOR -> getFirstCreatorAuthor(context.psiMethod) + ValueProviderType.FIRST_CREATOR_TIME -> getFirstCreatorTime(context.psiMethod) + } + } + + private fun getGitAuthor(): String { + return GitUtil.getGitUserInfo(project).toString() + } + + private fun getLastModifierAuthor(psiMethod: PsiMethod): String { + return GitUtil.getLastModifyInfo(project, psiMethod)?.toString() + ?: getGitAuthor() + } + + private fun getLastModifierTime(psiMethod: PsiMethod): String { + var timestamp = GitUtil.getLastModifyInfo(project, psiMethod)?.timestamp + timestamp = timestamp?.times(1000) + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + return dateFormat.format(Date((timestamp ?: System.currentTimeMillis()))) + } + + private fun getCurrentDate(format: String?): String { + val dateFormat = SimpleDateFormat(format ?: "yyyy-MM-dd HH:mm:ss") + return dateFormat.format(Date()) + } + + private fun generateDescription(psiMethod: PsiMethod): String { + val methodName = psiMethod.name + // Convert camelCase or snake_case to space-separated words + return methodName.replace(Regex("([a-z])([A-Z])"), "$1 $2") + .replace("_", " ") + .lowercase() + .capitalizeFirst() + } + + private fun guessClassUnderTestClassName(psiClass: PsiClass): String { + // Get base class name without Test prefix/suffix + val baseClassName = psiClass.name?.removePrefix("Test")?.removeSuffix("Test") ?: return "" + + // Get test class package and normalize it + val testPackage = psiClass.qualifiedName + ?.removeSuffix(psiClass.name!!) + ?.removeSuffix(".") + ?.replace(".test.", ".") + ?.replace(".tests.", ".") + ?.removeSuffix(".test") + ?.removeSuffix(".tests") + ?: return "" + + return "$testPackage.$baseClassName" + } + + private fun guessMethodUnderTestMethodName(psiMethod: PsiMethod): String { + return psiMethod.name + .removePrefix("test") + .removePrefix("should") + .removePrefix("testShould") + // Take only the part before first underscore if it exists + .split("_") + .first() + .lowercase() // just lowercase for case-insensitive matching + } + + private fun tryGetMethodUnderTestSignature(psiClass: PsiClass, psiMethod: PsiMethod): String? { + val guessedClassName = guessClassUnderTestClassName(psiClass) + val guessedMethodName = guessMethodUnderTestMethodName(psiMethod) + .lowercase() // normalize to lowercase for comparison + + // Find the class under test using PSI API + val project = psiClass.project + val psiManager = PsiManager.getInstance(project) + val psiFacade = JavaPsiFacade.getInstance(project) + + // Try to find the class under test + val classUnderTest = psiFacade.findClass(guessedClassName, GlobalSearchScope.projectScope(project)) + ?: return null + + // Skip if the target class is a test class + val hasTestMethods = classUnderTest.methods.any { method -> + method.annotations.any { annotation -> + val annotationName = annotation.qualifiedName + annotationName == "org.junit.jupiter.api.Test" || // JUnit 5 + annotationName == "org.junit.Test" || // JUnit 4 + annotationName?.contains("Test") ?: false // Custom test annotation + } + } + + if (hasTestMethods) { + return null + } + + // Find matching method in the class under test + val methodUnderTest = classUnderTest.methods.firstOrNull { method -> + method.name.lowercase().contains(guessedMethodName.lowercase()) + } ?: return null + + // Return the method signature + return getMethodSignature(methodUnderTest) + } + + private fun getMethodSignature(psiMethod: PsiMethod): String { + return buildString { + // Add visibility modifier (excluding annotations) + psiMethod.modifierList.text.trim() + .split(" ") + .filter { it.isNotEmpty() && !it.startsWith("@") } + .joinTo(this, " ") + append(" ") + + // Add return type if not constructor + if (!psiMethod.isConstructor) { + append(psiMethod.returnType?.presentableText ?: "void") + append(" ") + } + + // Add method name and parameters + append(psiMethod.name) + append("(") + append(psiMethod.parameterList.parameters.joinToString(", ") { param -> + "${param.type.presentableText} ${param.name}" + }) + append(")") + + // Add throws clause if present + val throwsList = psiMethod.throwsList.referencedTypes + if (throwsList.isNotEmpty()) { + append(" throws ") + append(throwsList.joinToString(", ") { it.presentableText }) + } + }.replace("\n", " ").replace(Regex("\\s+"), " ").trim() + } + + private fun getFirstCreatorAuthor(psiMethod: PsiMethod): String { + return GitUtil.getFirstCreatorInfo(project, psiMethod)?.toString() + ?: getGitAuthor() + } + + private fun getFirstCreatorTime(psiMethod: PsiMethod): String { + var timestamp = GitUtil.getFirstCreatorInfo(project, psiMethod)?.timestamp + timestamp = timestamp?.times(1000) + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + return dateFormat.format(Date((timestamp ?: System.currentTimeMillis()))) + } +} + +private fun String.capitalizeFirst(): String { + return if (isNotEmpty()) { + this[0].uppercase() + substring(1) + } else this +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/ui/AnnotationConfigurable.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/ui/AnnotationConfigurable.kt new file mode 100644 index 0000000..128121e --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/ui/AnnotationConfigurable.kt @@ -0,0 +1,169 @@ +package com.github.jaksonlin.pitestintellij.ui + +import com.github.jaksonlin.pitestintellij.services.AnnotationConfigService +import com.github.jaksonlin.pitestintellij.annotations.AnnotationSchema +import com.intellij.json.JsonFileType +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.project.ProjectManager +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.panels.VerticalLayout +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.FlowLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.Insets +import javax.swing.JButton +import javax.swing.JCheckBox +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTextField + +class AnnotationConfigurable : Configurable { + private var editor: EditorTextField? = null + private var packageTextField: JTextField? = null + private var autoImportCheckbox: JCheckBox? = null + private val configService = service() + + override fun getDisplayName(): String = "Test Annotation Configuration" + + override fun createComponent(): JComponent { + val mainPanel = JBPanel>(VerticalLayout(10)) + + // Import Settings Section (now at top) + mainPanel.add(createImportSettingsPanel()) + + // Schema Editor Section + mainPanel.add(JBLabel("Annotation Schema:")) + mainPanel.add(createSchemaEditor()) + + // Buttons Panel + mainPanel.add(createButtonsPanel()) + + // Help Panel + mainPanel.add(createHelpPanel()) + + return mainPanel + } + + private fun createSchemaEditor(): JComponent { + val project = ProjectManager.getInstance().defaultProject + + editor = EditorTextField( + EditorFactory.getInstance().createDocument(configService.state.schemaJson), + project, + JsonFileType.INSTANCE, + false, + false + ).apply { + setOneLineMode(false) + setPreferredWidth(400) + addSettingsProvider { editor -> + editor.settings.apply { + isLineNumbersShown = true + isWhitespacesShown = true + isUseSoftWraps = true + } + } + } + + return JBScrollPane(editor) + } + + private fun createImportSettingsPanel(): JComponent { + val panel = JPanel(GridBagLayout()) + val gbc = GridBagConstraints().apply { + insets = Insets(5, 5, 5, 5) + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.BASELINE_LEADING // Align items on the same line + } + + // Package Label + gbc.gridx = 0 + gbc.gridy = 0 + gbc.weightx = 0.0 + panel.add(JBLabel("Annotation Package:"), gbc) + + // Package TextField + packageTextField = JTextField(configService.getAnnotationPackage()).apply { + preferredSize = Dimension(200, preferredSize.height) + } + gbc.gridx = 1 + gbc.weightx = 1.0 + panel.add(packageTextField, gbc) + + // Auto Import Checkbox + autoImportCheckbox = JCheckBox("Auto Import", configService.isAutoImport()) + gbc.gridx = 2 + gbc.weightx = 0.0 + gbc.insets.left = 20 // Add some space between textfield and checkbox + panel.add(autoImportCheckbox, gbc) + + return panel + } + + private fun createButtonsPanel(): JComponent { + return JPanel(FlowLayout(FlowLayout.LEFT)).apply { + add(JButton("Restore Defaults").apply { + addActionListener { + editor?.text = AnnotationSchema.DEFAULT_SCHEMA + packageTextField?.text = "com.example.unittest.annotations" + autoImportCheckbox?.isSelected = true + } + }) + } + } + + private fun createHelpPanel(): JComponent { + return JPanel(BorderLayout()).apply { + add(JBLabel(""" + Define your test annotation schema in JSON format. + Available field types: STRING, STRING_LIST, STATUS + + Package: The base package for your annotations + Auto Import: Automatically add import statements when generating annotations + """.trimIndent()), BorderLayout.CENTER) + } + } + + override fun isModified(): Boolean { + return editor?.text != configService.state.schemaJson || + packageTextField?.text != configService.getAnnotationPackage() || + autoImportCheckbox?.isSelected != configService.isAutoImport() + } + + override fun apply() { + val jsonText = editor?.text ?: return + try { + // Validate JSON format and schema + val schema = Json.decodeFromString(jsonText) + configService.state.schemaJson = jsonText + + // Update import settings + packageTextField?.text?.let { configService.setAnnotationPackage(it) } + autoImportCheckbox?.isSelected?.let { configService.setAutoImport(it) } + } catch (e: Exception) { + throw ConfigurationException("Invalid JSON schema: ${e.message}") + } + } + + override fun reset() { + editor?.text = configService.state.schemaJson + packageTextField?.text = configService.getAnnotationPackage() + autoImportCheckbox?.isSelected = configService.isAutoImport() + } + + override fun disposeUIResources() { + editor = null + packageTextField = null + autoImportCheckbox = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/ui/CustomAnnotationCompletionLookupElement.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/ui/CustomAnnotationCompletionLookupElement.kt new file mode 100644 index 0000000..d9e1001 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/ui/CustomAnnotationCompletionLookupElement.kt @@ -0,0 +1,46 @@ +package com.github.jaksonlin.pitestintellij.ui + +import com.github.jaksonlin.pitestintellij.annotations.AnnotationFieldType +import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementPresentation +import com.intellij.icons.AllIcons + +class CustomAnnotationCompletionLookupElement( + private val value: String, + private val fieldType: AnnotationFieldType, + private val isDefaultValue: Boolean = false +) : LookupElement() { + + override fun getLookupString(): String = value + + override fun renderElement(presentation: LookupElementPresentation) { + presentation.itemText = value + presentation.typeText = fieldType.toString() + + // Add appropriate icon based on type + presentation.icon = when (fieldType) { + AnnotationFieldType.STRING_LIST -> AllIcons.Nodes.EntryPoints + else -> AllIcons.Nodes.Field + } + + if (isDefaultValue) { + presentation.isItemTextBold = true + presentation.typeText = "Default" + } + } + + override fun handleInsert(context: InsertionContext) { + val document = context.document + val startOffset = context.startOffset + val tailOffset = context.tailOffset + + // Insert quotes around the value + document.insertString(startOffset, "\"") + document.insertString(tailOffset+1, "\"") + + // Move caret after the closing quote + context.editor.caretModel.moveToOffset(tailOffset + 2) + + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/util/GitUtil.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/util/GitUtil.kt new file mode 100644 index 0000000..df64ce5 --- /dev/null +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/util/GitUtil.kt @@ -0,0 +1,252 @@ +package com.github.jaksonlin.pitestintellij.util + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import git4idea.repo.GitRepository +import git4idea.repo.GitRepositoryManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import git4idea.commands.Git +import git4idea.commands.GitCommand +import git4idea.commands.GitLineHandler +import git4idea.config.GitConfigUtil +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.psi.PsiMethod + +object GitUtil { + fun getRepositoryManager(project: Project): GitRepositoryManager { + return GitRepositoryManager.getInstance(project) + } + + fun getCurrentRepository(project: Project, element: PsiElement): GitRepository? { + val file = element.containingFile?.virtualFile + return file?.let { getRepositoryForFile(project, it) } + } + + fun getRepositoryForFile(project: Project, file: VirtualFile): GitRepository? { + return GitRepositoryManager.getInstance(project).getRepositoryForFile(file) + } + + fun getGitUserInfo(project: Project): GitUserInfo { + val repository = getRepositoryManager(project).repositories.firstOrNull() + return repository?.let { + val name = ApplicationManager.getApplication().executeOnPooledThread { + GitConfigUtil.getValue(project, repository.root, GitConfigUtil.USER_NAME) + }.get() + val email = ApplicationManager.getApplication().executeOnPooledThread { + GitConfigUtil.getValue(project, repository.root, GitConfigUtil.USER_EMAIL) + }.get() + GitUserInfo( + name = name ?: "Unknown", + email = email ?: "unknown@email.com" + ) + } ?: GitUserInfo("Unknown", "unknown@email.com") + } + + + + fun getLastCommitInfo(project: Project, file: PsiFile): GitCommitInfo? { + val repository = file.virtualFile?.let { getRepositoryForFile(project, it) } ?: return null + val git = Git.getInstance() + + val handler = GitLineHandler( + project, + repository.root, + GitCommand.LOG + ).apply { + addParameters( + "--max-count=1", + "--pretty=format:%an|%ae|%ad|%s", + "--date=format:%Y-%m-%d %H:%M:%S", + "--", + file.virtualFile.path + ) + } + val output = ApplicationManager.getApplication().executeOnPooledThread> { + git.runCommand(handler).output + }.get() + return output.firstOrNull()?.let { line -> + val parts = line.split("|") + if (parts.size >= 4) { + GitCommitInfo( + author = parts[0], + email = parts[1], + date = parts[2], + message = parts[3] + ) + } else null + } + } + + fun getFirstCreatorInfo(project: Project, psiMethod: PsiMethod): GitUserInfo? { + val file = psiMethod.containingFile?.virtualFile ?: return null + val repository = getRepositoryForFile(project, file) ?: return null + val git = Git.getInstance() + + // Get method's line range + val document = FileDocumentManager.getInstance().getDocument(file) ?: return null + val startOffset = psiMethod.textRange.startOffset + val endOffset = psiMethod.textRange.endOffset + val startLine = document.getLineNumber(startOffset) + 1 + val endLine = document.getLineNumber(endOffset) + 1 + + val handler = GitLineHandler( + project, + repository.root, + GitCommand.BLAME + ).apply { + addParameters( + "-L", "$startLine,$endLine", + "--porcelain", + file.path + ) + } + + val output = ApplicationManager.getApplication().executeOnPooledThread> { + git.runCommand(handler).output + }.get() + + // Get only the first commit's information + var authorName: String? = null + var email: String? = null + var timestamp: Long? = null + + // Only process lines until we find the first complete record + for (line in output) { + when { + line.startsWith("author ") -> authorName = line.substringAfter("author ").trim() + line.startsWith("author-mail ") -> email = line.substringAfter("author-mail ").trim().removeSurrounding("<", ">") + line.startsWith("author-time ") -> { + timestamp = line.substringAfter("author-time ").trim().toLongOrNull() + if (timestamp != null && authorName != null && email != null) { + break // Exit as soon as we have all needed information + } + } + } + } + + // Handle uncommitted changes + if (authorName == "Not Committed Yet" || email == "not.committed.yet") { + val gitUserInfo = getGitUserInfo(project) + return GitUserInfo( + name = gitUserInfo.name, + email = gitUserInfo.email, + timestamp = timestamp ?: System.currentTimeMillis() + ) + } + + return if (authorName != null && email != null) { + GitUserInfo( + name = authorName, + email = email, + timestamp = timestamp ?: System.currentTimeMillis() + ) + } else null + } + + fun getLastModifyInfo(project: Project, psiMethod: PsiMethod): GitUserInfo? { + val file = psiMethod.containingFile?.virtualFile ?: return null + val repository = getRepositoryForFile(project, file) ?: return null + val git = Git.getInstance() + + // Get method's line range + val document = FileDocumentManager.getInstance().getDocument(file) ?: return null + val startOffset = psiMethod.textRange.startOffset + val endOffset = psiMethod.textRange.endOffset + val startLine = document.getLineNumber(startOffset) + 1 // Git blame uses 1-based line numbers + val endLine = document.getLineNumber(endOffset) + 1 + + val handler = GitLineHandler( + project, + repository.root, + GitCommand.BLAME + ).apply { + addParameters( + "-L", "$startLine,$endLine", // Limit blame to method's lines + "--porcelain", // Get detailed output + file.path + ) + } + + val output = ApplicationManager.getApplication().executeOnPooledThread> { + git.runCommand(handler).output + }.get() + + // Parse blame output to get the most recent commit + var latestCommit: BlameInfo? = null + var currentCommit: BlameInfo? = null + + output.forEach { line -> + when { + line.startsWith("author ") -> { + val authorName = line.substringAfter("author ").trim() + if (authorName == "Not Committed Yet") { + val gitUserInfo = getGitUserInfo(project) + currentCommit = currentCommit?.copy( + author = gitUserInfo.name, + email = gitUserInfo.email + ) ?: BlameInfo(author = gitUserInfo.name, email = gitUserInfo.email) + } else { + currentCommit = currentCommit?.copy( + author = authorName + ) ?: BlameInfo(author = authorName) + } + } + line.startsWith("author-mail ") -> { + val email = line.substringAfter("author-mail ").trim().removeSurrounding("<", ">") + if (email == "not.committed.yet") { + val gitUserInfo = getGitUserInfo(project) + currentCommit = currentCommit?.copy( + author = gitUserInfo.name, + email = gitUserInfo.email + ) ?: BlameInfo(author = gitUserInfo.name, email = gitUserInfo.email) + } else { + currentCommit = currentCommit?.copy( + email = email + ) ?: BlameInfo(author = currentCommit?.author ?: "", email = email) + } + } + line.startsWith("author-time ") -> { + val timestamp = line.substringAfter("author-time ").trim().toLongOrNull() + if (timestamp != null) { + currentCommit = currentCommit?.copy(timestamp = timestamp) + if (latestCommit == null || (currentCommit?.timestamp ?: 0) > (latestCommit?.timestamp ?: 0)) { + currentCommit?.let { latestCommit = it } + } + } + currentCommit = null // Reset for next commit + } + } + } + + return latestCommit?.let { + GitUserInfo( + name = it.author, + email = it.email ?: "unknown@email.com", + timestamp = it.timestamp ?: System.currentTimeMillis() // If timestamp is not available, use current time + ) + } + } + + private data class BlameInfo( + val author: String, + val email: String? = null, + val timestamp: Long = 0 + ) +} + +data class GitUserInfo( + val name: String, + val email: String, + val timestamp: Long = 0 +) { + override fun toString(): String = "$name <$email>" +} + +data class GitCommitInfo( + val author: String, + val email: String, + val date: String, + val message: String +) diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/util/GradleUtils.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/util/GradleUtils.kt index 6ca62f3..01d272e 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/util/GradleUtils.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/util/GradleUtils.kt @@ -1,6 +1,7 @@ package com.github.jaksonlin.pitestintellij.util import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project import com.intellij.openapi.project.modules import com.intellij.openapi.project.rootManager @@ -34,11 +35,30 @@ object GradleUtils { return outputPaths } - // find the direct parent module's base directory of the child module in the project +// // find the direct parent module's base directory of the child module in the project +// fun getUpperModulePath(project: Project, childModule: Module): String { +// var candidateModuleName = "" +// var candidateModule: Module? = null +// for (module in project.modules) { +// val moduleName = module.name +// if (childModule.name.contains(moduleName) && childModule.name != moduleName) { +// if (moduleName.length > candidateModuleName.length) { +// candidateModuleName = moduleName +// candidateModule = module +// } +// } +// } +// val contentRoots = candidateModule?.rootManager?.contentRoots +// return if (contentRoots != null && contentRoots.isNotEmpty()) { +// contentRoots[0].path +// } else { +// "" +// } +// } fun getUpperModulePath(project:Project, childModule:Module):String{ var candidateModuleName = "" var candidateModule: Module? = null - for (module in project.modules) { + for (module in ModuleManager.getInstance(project).modules) { val moduleName = module.name if (childModule.name.contains(moduleName) && childModule.name != moduleName) { if (moduleName.length > candidateModuleName.length){ @@ -47,7 +67,7 @@ object GradleUtils { } } } - return candidateModule?.rootManager?.contentRoots?.get(0)?.path ?: "" + return candidateModule?.rootManager?.contentRoots?.firstOrNull()?.path ?: "" } fun getTestRunDependencies(project: Project): List { diff --git a/src/main/kotlin/com/github/jaksonlin/pitestintellij/viewmodels/MutationTreeMediatorViewModel.kt b/src/main/kotlin/com/github/jaksonlin/pitestintellij/viewmodels/MutationTreeMediatorViewModel.kt index 1cbd32d..dc71e2d 100644 --- a/src/main/kotlin/com/github/jaksonlin/pitestintellij/viewmodels/MutationTreeMediatorViewModel.kt +++ b/src/main/kotlin/com/github/jaksonlin/pitestintellij/viewmodels/MutationTreeMediatorViewModel.kt @@ -133,7 +133,7 @@ class MutationTreeMediatorViewModel( override fun hashCode(): Int { var result = icon.hashCode() - result = 31 * result + (tooltipText.hashCode() ?: 0) + result = 31 * result + tooltipText.hashCode() return result } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 333a5b9..4295574 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -31,11 +31,29 @@ com.intellij.modules.platform com.intellij.modules.java + Git4Idea messages.MyBundle + + + + @@ -58,6 +76,14 @@ id="RunPitestAction" text="Run Mutation Test"> + + + + diff --git a/src/main/resources/inspectionDescriptions/UnittestCaseAnnotationInspection.html b/src/main/resources/inspectionDescriptions/UnittestCaseAnnotationInspection.html new file mode 100644 index 0000000..5365a23 --- /dev/null +++ b/src/main/resources/inspectionDescriptions/UnittestCaseAnnotationInspection.html @@ -0,0 +1,27 @@ + + +Write your description here. +Start the description with a verb in 3rd person singular, like reports, detects, highlights. +In the first sentence, briefly explain what exactly the inspection helps you detect. +Make sure the sentence is not very long and complicated. +

+ The first sentence must be in a dedicated paragraph separated from the rest of the text. This will make the + description easier to read. + Make sure the description doesn’t just repeat the inspection title. +

+

+ See https://plugins.jetbrains.com/docs/intellij/inspections.html#descriptions for more information. +

+

+ Embed code snippets: +

+

+// automatically highlighted according to inspection registration 'language' attribute
+
+ +

Text after this comment will only be shown in the settings of the inspection.

+ +

To open related settings directly from the description, add a link with `settings://$` optionally followed by `?$` to + pre-select a UI element.

+ + \ No newline at end of file diff --git a/src/main/resources/messages/MyBundle_en_US.properties b/src/main/resources/messages/MyBundle_en_US.properties index 6ae7879..41bf3cb 100644 --- a/src/main/resources/messages/MyBundle_en_US.properties +++ b/src/main/resources/messages/MyBundle_en_US.properties @@ -1,3 +1,6 @@ mutation.tree.root=PiTest Mutation History clear.button=Clear All History -search.placeholder=Search History \ No newline at end of file +search.placeholder=Search History +inspection.group.name=Pitest Unit Test Specification +inspection.display.name=Pitest Unit Test Specification Inspection +inspection.problem.descriptor=Missing or invalid unit test annotation \ No newline at end of file diff --git a/src/main/resources/messages/MyBundle_zh_CN.properties b/src/main/resources/messages/MyBundle_zh_CN.properties index afa9fc4..eb2e760 100644 --- a/src/main/resources/messages/MyBundle_zh_CN.properties +++ b/src/main/resources/messages/MyBundle_zh_CN.properties @@ -1,3 +1,6 @@ mutation.tree.root=PiTest变异测试历史 clear.button=清除所有历史 -search.placeholder=搜索历史 \ No newline at end of file +search.placeholder=搜索历史 +inspection.group.name=Pitest单元测试规范 +inspection.display.name=Pitest单元测试规范检查 +inspection.problem.descriptor=缺少或无效的单元测试注解 \ No newline at end of file diff --git a/src/test/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationValidatorTest.kt b/src/test/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationValidatorTest.kt new file mode 100644 index 0000000..95d1745 --- /dev/null +++ b/src/test/kotlin/com/github/jaksonlin/pitestintellij/annotations/AnnotationValidatorTest.kt @@ -0,0 +1,327 @@ +package com.github.jaksonlin.pitestintellij.annotations + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith + +@RunWith(Enclosed::class) +class AnnotationValidatorTest { + + class ListLengthValidation { + @Test + fun `should validate minimum list length`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "testPoints", + type = AnnotationFieldType.STRING_LIST, + validation = FieldValidation( + minLength = 2 + ) + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test invalid case (too few elements) + val resultInvalid = validator.validate(mapOf( + "testPoints" to listOf("point1") + )) + assertTrue(resultInvalid is AnnotationValidator.ValidationResult.Invalid) + assertEquals( + "testPoints must contain at least 2 elements", + (resultInvalid as AnnotationValidator.ValidationResult.Invalid).errors.first() + ) + + // Test valid case + val resultValid = validator.validate(mapOf( + "testPoints" to listOf("point1", "point2") + )) + assertTrue(resultValid is AnnotationValidator.ValidationResult.Valid) + } + + @Test + fun `should validate maximum list length`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "tags", + type = AnnotationFieldType.STRING_LIST, + validation = FieldValidation( + maxLength = 3 + ) + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test invalid case (too many elements) + val resultInvalid = validator.validate(mapOf( + "tags" to listOf("tag1", "tag2", "tag3", "tag4") + )) + assertTrue(resultInvalid is AnnotationValidator.ValidationResult.Invalid) + assertEquals( + "tags cannot contain more than 3 elements", + (resultInvalid as AnnotationValidator.ValidationResult.Invalid).errors.first() + ) + + // Test valid case + val resultValid = validator.validate(mapOf( + "tags" to listOf("tag1", "tag2", "tag3") + )) + assertTrue(resultValid is AnnotationValidator.ValidationResult.Valid) + } + } + + class ValidValuesValidation { + @Test + fun `should validate string list allowed values when custom values not allowed`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "testPoints", + type = AnnotationFieldType.STRING_LIST, + validation = FieldValidation( + validValues = listOf("Valid1", "Valid2", "Valid3"), + allowCustomValues = false + ) + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test invalid case (contains invalid value) + val resultInvalid = validator.validate(mapOf( + "testPoints" to listOf("Valid1", "Invalid") + )) + assertTrue(resultInvalid is AnnotationValidator.ValidationResult.Invalid) + assertTrue( + (resultInvalid as AnnotationValidator.ValidationResult.Invalid) + .errors.first() + .contains("Invalid values for testPoints: Invalid") + ) + + // Test valid case + val resultValid = validator.validate(mapOf( + "testPoints" to listOf("Valid1", "Valid2") + )) + assertTrue(resultValid is AnnotationValidator.ValidationResult.Valid) + } + + @Test + fun `should allow custom values when configured`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "testPoints", + type = AnnotationFieldType.STRING_LIST, + validation = FieldValidation( + validValues = listOf("Valid1", "Valid2"), + allowCustomValues = true + ) + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test with custom value (should be valid) + val result = validator.validate(mapOf( + "testPoints" to listOf("Valid1", "CustomValue") + )) + assertTrue(result is AnnotationValidator.ValidationResult.Valid) + } + } + + class RequiredFieldValidation { + @Test + fun `should validate required fields`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "required", + type = AnnotationFieldType.STRING, + required = true + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test missing required field + val resultMissing = validator.validate(mapOf()) + assertTrue(resultMissing is AnnotationValidator.ValidationResult.Invalid) + assertEquals( + "Missing required field: required", + (resultMissing as AnnotationValidator.ValidationResult.Invalid).errors.first() + ) + + // Test with required field + val resultValid = validator.validate(mapOf( + "required" to "value" + )) + assertTrue(resultValid is AnnotationValidator.ValidationResult.Valid) + } + } + + class TypeValidation { + @Test + fun `should validate field types`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "stringField", + type = AnnotationFieldType.STRING + ), + AnnotationFieldConfig( + name = "listField", + type = AnnotationFieldType.STRING_LIST + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test invalid types + val resultInvalid = validator.validate(mapOf( + "stringField" to listOf("wrong type"), + "listField" to "wrong type" + )) + assertTrue(resultInvalid is AnnotationValidator.ValidationResult.Invalid) + assertEquals(2, (resultInvalid as AnnotationValidator.ValidationResult.Invalid).errors.size) + + // Test valid types + val resultValid = validator.validate(mapOf( + "stringField" to "correct", + "listField" to listOf("correct") + )) + assertTrue(resultValid is AnnotationValidator.ValidationResult.Valid) + } + } + + class StringContainsValidation { + @Test + fun `should validate string using contains mode`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "status", + type = AnnotationFieldType.STRING, + validation = FieldValidation( + validValues = listOf("TODO", "DONE"), + allowCustomValues = false, + mode = ValidationMode.CONTAINS + ) + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test valid cases (partial matches) + val resultValid1 = validator.validate(mapOf( + "status" to "TODO_REVIEW" + )) + assertTrue(resultValid1 is AnnotationValidator.ValidationResult.Valid) + + val resultValid2 = validator.validate(mapOf( + "status" to "DONE_WITH_COMMENTS" + )) + assertTrue(resultValid2 is AnnotationValidator.ValidationResult.Valid) + + // Test invalid case + val resultInvalid = validator.validate(mapOf( + "status" to "IN_PROGRESS" + )) + assertTrue(resultInvalid is AnnotationValidator.ValidationResult.Invalid) + } + } + + class EmptyValueValidation { + @Test + fun `should validate non-empty strings when required`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "title", + type = AnnotationFieldType.STRING, + validation = FieldValidation( + allowEmpty = false + ) + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test empty string + val resultEmpty = validator.validate(mapOf( + "title" to "" + )) + assertTrue(resultEmpty is AnnotationValidator.ValidationResult.Invalid) + assertEquals( + "Field title cannot be empty", + (resultEmpty as AnnotationValidator.ValidationResult.Invalid).errors.first() + ) + + // Test blank string + val resultBlank = validator.validate(mapOf( + "title" to " " + )) + assertTrue(resultBlank is AnnotationValidator.ValidationResult.Invalid) + + // Test valid non-empty string + val resultValid = validator.validate(mapOf( + "title" to "Valid Title" + )) + assertTrue(resultValid is AnnotationValidator.ValidationResult.Valid) + } + + @Test + fun `should validate non-empty lists when required`() { + val schema = AnnotationSchema( + annotationClassName = "Test", + fields = listOf( + AnnotationFieldConfig( + name = "tags", + type = AnnotationFieldType.STRING_LIST, + validation = FieldValidation( + allowEmpty = false + ) + ) + ) + ) + + val validator = AnnotationValidator(schema) + + // Test empty list + val resultEmpty = validator.validate(mapOf( + "tags" to emptyList() + )) + assertTrue(resultEmpty is AnnotationValidator.ValidationResult.Invalid) + assertEquals( + "Field tags cannot be empty", + (resultEmpty as AnnotationValidator.ValidationResult.Invalid).errors.first() + ) + + // Test valid non-empty list + val resultValid = validator.validate(mapOf( + "tags" to listOf("tag1") + )) + assertTrue(resultValid is AnnotationValidator.ValidationResult.Valid) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/github/jaksonlin/pitestintellij/completion/AnnotationCompletionTest.kt b/src/test/kotlin/com/github/jaksonlin/pitestintellij/completion/AnnotationCompletionTest.kt new file mode 100644 index 0000000..75869d0 --- /dev/null +++ b/src/test/kotlin/com/github/jaksonlin/pitestintellij/completion/AnnotationCompletionTest.kt @@ -0,0 +1,192 @@ +package com.github.jaksonlin.pitestintellij.completion + +import com.github.jaksonlin.pitestintellij.annotations.* +import com.github.jaksonlin.pitestintellij.services.AnnotationConfigService +import com.github.jaksonlin.pitestintellij.testutil.TestBase +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import com.intellij.codeInsight.lookup.LookupElementPresentation +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.LogLevel +import com.intellij.openapi.diagnostic.Logger +import com.intellij.psi.PsiElement + +class AnnotationCompletionTest : LightJavaCodeInsightFixtureTestCase(), TestBase { + + private lateinit var configService: AnnotationConfigService + + override fun setUp() { + super.setUp() + setupServices(getTestRootDisposable()) // Pass the disposable from parent class + + // Add the UnitTest annotation class + myFixture.addClass(""" + public @interface UnitTest { + String status() default "TODO"; + String[] testPoints() default {}; + } + """.trimIndent()) + + // Get the registered service + configService = project.getService(AnnotationConfigService::class.java) + + // Set up test schema + val testSchema = AnnotationSchema( + annotationClassName = "UnitTest", + fields = listOf( + AnnotationFieldConfig( + name = "status", + type = AnnotationFieldType.STRING, + validation = FieldValidation( + validValues = listOf("TODO", "IN_PROGRESS", "DONE"), + allowCustomValues = false, + mode = ValidationMode.EXACT + ), + defaultValue = DefaultValue.StringValue("TODO") + ), + AnnotationFieldConfig( + name = "testPoints", + type = AnnotationFieldType.STRING_LIST, + validation = FieldValidation( + validValues = listOf( + "Boundary Value", + "Error Handling", + "Performance", + "Security" + ), + allowCustomValues = true, + mode = ValidationMode.CONTAINS + ), + defaultValue = DefaultValue.StringListValue(emptyList()) + ) + ) + ) + + configService.updateSchema(testSchema) + } + + + + + fun testStatusCompletion() { + myFixture.configureByText("TestClass.java", """ + @UnitTest(status = "") + public class TestClass { + } + """.trimIndent()) + + // Type the opening quote and a character to trigger completion + myFixture.type("T") + myFixture.completeBasic() + + // Get all lookup elements + val lookupElements = myFixture.lookupElements + assertNotNull("Should have completions", lookupElements) + + val completions = lookupElements!!.map { it.lookupString } + println("Available completions: ${completions.joinToString()}") + + assertTrue("Should contain TODO", completions.contains("TODO")) + assertTrue("Should contain at least one completion", completions.isNotEmpty()) + } + + fun testTestPointsCompletion() { + myFixture.configureByText("TestClass.java", """ + @UnitTest(testPoints = {""}) + public class TestClass { + } + """.trimIndent()) + + val completions = myFixture.completeBasic() + assertNotNull(completions) + assertTrue(completions.any { it.lookupString == "BoundaryValue" }) + assertTrue(completions.any { it.lookupString == "ErrorHandling" }) + assertTrue(completions.any { it.lookupString == "Performance" }) + assertTrue(completions.any { it.lookupString == "Security" }) + } + + fun testNoCompletionForUnknownAnnotation() { + myFixture.configureByText("TestClass.java", """ + @SomeOtherAnnotation(value = "") + public class TestClass { + } + """.trimIndent()) + + val completions = myFixture.completeBasic() + assertTrue(completions == null || completions.none { + it.lookupString in listOf("TODO", "DONE", "Boundary Value") + }) + } + + fun testNoCompletionForUnknownField() { + myFixture.configureByText("TestClass.java", """ + @UnitTest(unknownField = "") + public class TestClass { + } + """.trimIndent()) + + val completions = myFixture.completeBasic() + assertTrue(completions == null || completions.none { + it.lookupString in listOf("TODO", "DONE", "Boundary Value") + }) + } + + fun testServiceSetup() { + // Verify service is properly registered + val service = project.getService(AnnotationConfigService::class.java) + assertNotNull("AnnotationConfigService should be registered", service) + + // Verify schema is properly loaded + val schema = service.getSchema() + assertEquals("UnitTest", schema.annotationClassName) + // store the schema in a variable + + + // Verify fields are properly configured + val statusField = schema.fields.find { it.name == "status" } + assertNotNull("Status field should exist", statusField) + assertEquals(AnnotationFieldType.STRING, statusField!!.type) + + val testPointsField = schema.fields.find { it.name == "testPoints" } + assertNotNull("TestPoints field should exist", testPointsField) + assertEquals(AnnotationFieldType.STRING_LIST, testPointsField!!.type) + } + + fun testCompletionDebug() { + val positions = listOf( + """ + @UnitTest(status = ) + """, + """ + @UnitTest(status = "") + """, + """ + @UnitTest(status = "T") + """ + ) + // set the log level to debug + Logger.getInstance(AnnotationCompletionContributor::class.java).setLevel(LogLevel.DEBUG) + + positions.forEachIndexed { index, position -> + println("\nTesting position $index") + myFixture.configureByText("TestClass${index}.java", """ + ${position.trimIndent()} + public class TestClass { + } + """.trimIndent()) + + // Try completion + val completions = myFixture.completeBasic() + println("Completions at position $index: ${completions?.size ?: 0}") + completions?.forEach { + println(" ${it.lookupString}") + } + } + } + + private fun printPsiTree(element: PsiElement, indent: String = "") { + println("$indent${element.javaClass.simpleName}: '${element.text}'") + element.children.forEach { child -> + printPsiTree(child, "$indent ") + } + } +} diff --git a/src/test/kotlin/com/github/jaksonlin/pitestintellij/inspectors/UnittestInspectorPerformanceTest.kt b/src/test/kotlin/com/github/jaksonlin/pitestintellij/inspectors/UnittestInspectorPerformanceTest.kt new file mode 100644 index 0000000..3d9b16e --- /dev/null +++ b/src/test/kotlin/com/github/jaksonlin/pitestintellij/inspectors/UnittestInspectorPerformanceTest.kt @@ -0,0 +1,44 @@ +package com.github.jaksonlin.pitestintellij.inspectors +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import org.junit.Test + +class UnittestInspectorPerformanceTest : LightJavaCodeInsightFixtureTestCase() { + + @Test + fun `test inspection performance`() { + // Create a large test file + val testFileContent = buildString { + append(""" + import org.junit.Test; + + public class LargeTest { + """.trimIndent()) + + // Add 1000 test methods + for (i in 1..1000) { + append(""" + @Test + public void test$i() { + // Some test code + } + """.trimIndent()) + } + + append("}") + } + + myFixture.configureByText("LargeTest.java", testFileContent) + + // Measure performance + val startTime = System.nanoTime() + myFixture.enableInspections(UnittestInspector::class.java) + myFixture.doHighlighting() + val endTime = System.nanoTime() + + val durationMs = (endTime - startTime) / 1_000_000.0 + println("Inspection took $durationMs ms") + + // Assert reasonable performance (adjust threshold as needed) + assertTrue("Inspection took too long: $durationMs ms", durationMs < 3000) + } +} diff --git a/src/test/kotlin/com/github/jaksonlin/pitestintellij/testutil/TestBase.kt b/src/test/kotlin/com/github/jaksonlin/pitestintellij/testutil/TestBase.kt new file mode 100644 index 0000000..ede0632 --- /dev/null +++ b/src/test/kotlin/com/github/jaksonlin/pitestintellij/testutil/TestBase.kt @@ -0,0 +1,19 @@ +package com.github.jaksonlin.pitestintellij.testutil + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.github.jaksonlin.pitestintellij.services.AnnotationConfigService +import com.intellij.openapi.Disposable +import com.intellij.testFramework.replaceService + +interface TestBase { + // Remove the property requirement and use a parameter instead + fun setupServices(disposable: Disposable) { + val application = ApplicationManager.getApplication() + application.replaceService( + AnnotationConfigService::class.java, + AnnotationConfigService(), + disposable + ) + } +} \ No newline at end of file