Split unit test, add test to find duplicated SinceKtlint annotations
diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/ClassNamingRule.kt b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/ClassNamingRule.kt
index 0e70f5b..cefd627 100644
--- a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/ClassNamingRule.kt
+++ b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/ClassNamingRule.kt
@@ -22,7 +22,6 @@
  * well as it is more consistent with name of test functions.
  */
 @SinceKtlint("0.48", EXPERIMENTAL)
-@SinceKtlint("0.49", EXPERIMENTAL)
 @SinceKtlint("1.0", STABLE)
 public class ClassNamingRule : StandardRule("class-naming") {
     private var allowBacktickedClassName = false
diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/FunctionTypeReferenceSpacingRule.kt b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/FunctionTypeReferenceSpacingRule.kt
index da24d29..1eeb6c9 100644
--- a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/FunctionTypeReferenceSpacingRule.kt
+++ b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/rules/FunctionTypeReferenceSpacingRule.kt
@@ -17,7 +17,6 @@
 import org.jetbrains.kotlin.com.intellij.lang.ASTNode
 
 @SinceKtlint("0.45", EXPERIMENTAL)
-@SinceKtlint("0.49", EXPERIMENTAL)
 @SinceKtlint("1.0", STABLE)
 public class FunctionTypeReferenceSpacingRule : StandardRule("function-type-reference-spacing") {
     override fun beforeVisitChildNodes(
diff --git a/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/SinceKtlintAnnotationTest.kt b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/SinceKtlintAnnotationTest.kt
index 46fca44..883e820 100644
--- a/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/SinceKtlintAnnotationTest.kt
+++ b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/SinceKtlintAnnotationTest.kt
@@ -3,76 +3,108 @@
 import com.pinterest.ktlint.rule.engine.core.api.Rule
 import com.pinterest.ktlint.rule.engine.core.api.SinceKtlint
 import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Nested
 import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.params.Parameter
+import org.junit.jupiter.params.ParameterizedClass
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+import java.util.stream.Stream
 
 class SinceKtlintAnnotationTest {
-    @Test
-    fun `Given all rules then each rule should have proper SinceKtlint annotations`() {
-        val ruleSetProvider = StandardRuleSetProvider()
-        val rules = ruleSetProvider.getRuleProviders().map { it.createNewRuleInstance() }
+    @ParameterizedClass(name = "{argumentSetName}")
+    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+    @MethodSource("allRules")
+    @Nested
+    inner class `Given a STABLE or EXPERIMENTAL rule` {
+        @Parameter
+        lateinit var rule: Rule
 
-        val violations =
-            rules.flatMap { rule ->
-                val ruleClass = rule::class
-                val annotations = ruleClass.annotations.filterIsInstance<SinceKtlint>()
-                val isExperimental = rule is Rule.Experimental
+        @Test
+        fun `The rule has a valid version number not containing the patch level`() {
+            val actual =
+                rule
+                    .sinceKtlintAnnotations()
+                    .all { isValidVersionFormat(it.version) }
 
-                val annotationViolations =
-                    when {
-                        annotations.isEmpty() -> {
-                            listOf("${ruleClass.simpleName} has no @SinceKtlint annotations")
-                        }
+            assertThat(actual).isTrue
+        }
 
-                        isExperimental -> {
-                            val experimentalAnnotations = annotations.filter { it.status == SinceKtlint.Status.EXPERIMENTAL }
-                            val stableAnnotations = annotations.filter { it.status == SinceKtlint.Status.STABLE }
+        fun allRules(): Stream<Arguments> = rules { true }
 
-                            when {
-                                experimentalAnnotations.isEmpty() -> {
-                                    listOf(
-                                        "${ruleClass.simpleName} implements Experimental but has no EXPERIMENTAL @SinceKtlint annotation",
-                                    )
-                                }
-
-                                stableAnnotations.isNotEmpty() -> {
-                                    listOf("${ruleClass.simpleName} implements Experimental but has STABLE @SinceKtlint annotation")
-                                }
-
-                                else -> {
-                                    emptyList()
-                                }
-                            }
-                        }
-
-                        else -> {
-                            val stableAnnotations = annotations.filter { it.status == SinceKtlint.Status.STABLE }
-                            if (stableAnnotations.isEmpty()) {
-                                listOf("${ruleClass.simpleName} is stable but has no STABLE @SinceKtlint annotation")
-                            } else {
-                                emptyList()
-                            }
-                        }
-                    }
-
-                val versionViolations =
-                    annotations.mapNotNull { annotation ->
-                        if (!isValidVersionFormat(annotation.version)) {
-                            "${ruleClass.simpleName} has invalid version format '${annotation.version}' - should be 'X.Y' format without patch level"
-                        } else {
-                            null
-                        }
-                    }
-
-                annotationViolations + versionViolations
-            }
-
-        assertThat(violations)
-            .withFailMessage("Found @SinceKtlint annotation violations:\n${violations.joinToString("\n")}")
-            .isEmpty()
+        private fun isValidVersionFormat(version: String): Boolean {
+            val versionRegex = Regex("""^\d+\.\d+$""")
+            return versionRegex.matches(version)
+        }
     }
 
-    private fun isValidVersionFormat(version: String): Boolean {
-        val versionRegex = Regex("""^\d+\.\d+$""")
-        return versionRegex.matches(version)
+    @ParameterizedClass(name = "{argumentSetName}")
+    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+    @MethodSource("stableRules")
+    @Nested
+    inner class `Given a STABLE rule, eg not implementing interface Experimental` {
+        @Parameter
+        lateinit var stableRule: Rule
+
+        @Test
+        fun `The rule has exactly 1 @SinceKtlint annotation with status STABLE`() {
+            val actual =
+                stableRule
+                    .sinceKtlintAnnotations()
+                    .count { it.status == SinceKtlint.Status.STABLE }
+
+            assertThat(actual).isEqualTo(1)
+        }
+
+        @Test
+        fun `The rule has at most 1 @SinceKtlint annotation with status EXPERIMENTAL`() {
+            val actual =
+                stableRule
+                    .sinceKtlintAnnotations()
+                    .count { it.status == SinceKtlint.Status.EXPERIMENTAL }
+
+            assertThat(actual).isLessThanOrEqualTo(1)
+        }
+
+        fun stableRules(): Stream<Arguments> = rules { it !is Rule.Experimental }
     }
+
+    @ParameterizedClass(name = "{argumentSetName}")
+    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+    @MethodSource("experimentalRules")
+    @Nested
+    inner class `Given an EXPERIMENTAL rule, eg implementing interface Experimental` {
+        @Parameter
+        lateinit var experimentalRule: Rule
+
+        @Test
+        fun `The rule has exactly 1 @SinceKtlint annotation with status EXPERIMENTAL`() {
+            val actual =
+                experimentalRule
+                    .sinceKtlintAnnotations()
+                    .count { it.status == SinceKtlint.Status.EXPERIMENTAL }
+
+            assertThat(actual).isEqualTo(1)
+        }
+
+        @Test
+        fun `The rule should not have @SinceKtlint annotation with status STABLE`() {
+            val actual = experimentalRule.sinceKtlintAnnotations().none { it.status == SinceKtlint.Status.STABLE }
+
+            assertThat(actual).isTrue
+        }
+
+        fun experimentalRules(): Stream<Arguments> = rules { it is Rule.Experimental }
+    }
+
+    private fun Rule.sinceKtlintAnnotations(): List<SinceKtlint> = this::class.annotations.filterIsInstance<SinceKtlint>()
+
+    private fun rules(predicate2: (Rule) -> Boolean): Stream<Arguments> =
+        StandardRuleSetProvider()
+            .getRuleProviders()
+            .map { it.createNewRuleInstance() }
+            .filter { predicate2(it) }
+            .map { Arguments.argumentSet(it.ruleId.value, it) }
+            .let { argumentSets -> Stream.of(*argumentSets.toTypedArray()) }
 }