From a64cc1556db17122a69a37a8c4ccf33bdf81f355 Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Tue, 4 Aug 2026 23:44:23 +0400 Subject: [PATCH] feat(gherkin-to-asciidoc)!: add indexing DSL property to number features and scenarios Adds an `indexing` property (off/feature/scenario/all) that numbers Feature/Scenario titles directly in the source .feature files, alphabetically by feature file path, processing a directory's own files before its sub-directories'. Off strips any numbering left over from a previous run, so switching modes (including back to off) is idempotent. feature/all require groupByFeature = true; all four require includeSubDirs = true. BREAKING CHANGE: includeSubDirs and groupByFeature now default to true (previously false). A project relying on the old defaults - particularly one using sourceFile without explicitly setting includeSubDirs = false, which will now fail validation - must set includeSubDirs = false and/or groupByFeature = false explicitly to keep its previous behaviour. Feature file processing order is also now deterministic (alphabetical by path, directory files before sub-directory files) instead of filesystem-dependent, which may reorder scenarios in existing generated reports. Co-Authored-By: Claude Sonnet 5 --- .../gherkin/GenerateFeatureDocsTask.java | 59 +++- .../gherkin/GherkinToAsciidocExtension.java | 42 ++- .../gherkin/GherkinToAsciidocPlugin.java | 13 +- .../gherkin/indexing/FeatureIndexer.java | 111 +++++++ .../gradle/gherkin/indexing/IndexingMode.java | 31 ++ ...erkinToAsciidocMultiProjectPluginTest.java | 22 ++ .../gherkin/GherkinToAsciidocPluginTest.java | 288 +++++++++++++++++- .../gherkin/indexing/FeatureIndexerTest.java | 234 ++++++++++++++ 8 files changed, 782 insertions(+), 18 deletions(-) create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/IndexingMode.java create mode 100644 gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java index 9100328..8427040 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java @@ -1,6 +1,8 @@ package com.arc_e_tect.gradle.gherkin; import com.arc_e_tect.gradle.gherkin.glue.GlueCodeScanner; +import com.arc_e_tect.gradle.gherkin.indexing.FeatureIndexer; +import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode; import com.arc_e_tect.gradle.gherkin.parser.FeatureParser; import com.arc_e_tect.gradle.gherkin.parser.ScenarioGrouping; import com.arc_e_tect.gradle.gherkin.parser.ScenarioInfo; @@ -30,6 +32,7 @@ import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -156,6 +159,17 @@ public abstract class GenerateFeatureDocsTask extends DefaultTask { @Input public abstract Property getSystemUnderTestVersion(); + /** + * Whether - and how - to number {@code Feature}/{@code Scenario} titles directly in the + * source {@code .feature} files. Only allowed when {@link #getIncludeSubDirs()} is + * {@code true}; when {@link #getGroupByFeature()} is {@code false}, only + * {@link IndexingMode#OFF} and {@link IndexingMode#SCENARIO} are allowed. + * + * @return mutable property for the indexing mode + */ + @Input + public abstract Property getIndexing(); + /** * Root directory of the project, used to resolve the default source directory * when neither {@link #getSourceDirs()} nor {@link #getSourceFile()} is set. @@ -208,10 +222,23 @@ public void generate() { } } + IndexingMode indexing = getIndexing().get(); + boolean groupByFeature = getGroupByFeature().get(); + if (indexing != IndexingMode.OFF && !getIncludeSubDirs().get()) { + throw new GradleException( + "gherkinToAsciidoc: indexing can only be used when includeSubDirs is true."); + } + if (indexing != IndexingMode.OFF && indexing != IndexingMode.SCENARIO && !groupByFeature) { + throw new GradleException( + "gherkinToAsciidoc: when groupByFeature is false, indexing can only be " + + "'off' or 'scenario'."); + } + // trackProgress implies recursive scanning, regardless of includeSubDirs's own value. boolean recursive = trackProgress || getIncludeSubDirs().get(); List featureFiles = collectFeatureFiles(sourceDirsSet, sourceFileSet, recursive); + new FeatureIndexer().reindex(featureFiles, indexing); List scenarios = new ArrayList<>(); FeatureParser featureParser = new FeatureParser(); @@ -231,10 +258,10 @@ public void generate() { List glueCode = scanGlueCode(); File template = getTemplate().isPresent() ? getTemplate().getAsFile().get() : null; ProgressReportOptions options = new ProgressReportOptions( - getGroupByFeature().get(), getSnippetDir().getAsFile().get(), template, systemUnderTestVersion); + groupByFeature, getSnippetDir().getAsFile().get(), template, systemUnderTestVersion); new ProgressReportWriter().write(outputFile, scenarios, glueCode, options); } else { - writeAsciidoc(outputFile, scenarios, getGroupByFeature().get(), systemUnderTestVersion); + writeAsciidoc(outputFile, scenarios, groupByFeature, systemUnderTestVersion); } getLogger().lifecycle("Generated {} scenario title(s) to {}", scenarios.size(), outputFile); @@ -254,7 +281,12 @@ private List collectFeatureFiles(boolean sourceDirsSet, boolean sourceFile if (sourceFileSet) { files.add(getSourceFile().getAsFile().get()); } else if (sourceDirsSet) { - for (File dir : getSourceDirs()) { + // Ordered by path so that processing order (and thus indexing numbers, and the order + // scenarios appear in the generated report) is deterministic rather than + // filesystem-dependent, regardless of the order sourceDirs was configured in. + List dirs = new ArrayList<>(getSourceDirs().getFiles()); + dirs.sort(Comparator.comparing(File::getAbsolutePath)); + for (File dir : dirs) { collectFromDir(dir, files, recursive); } } else { @@ -265,6 +297,12 @@ private List collectFeatureFiles(boolean sourceDirsSet, boolean sourceFile return files; } + /** + * Collects {@code .feature} files from {@code dir} in pre-order: every {@code .feature} file + * directly in {@code dir} first (alphabetically by file name), then - when {@code recursive} + * is {@code true} - every direct sub-directory's own files, recursively, in the same fashion + * (sub-directories visited alphabetically by name). + */ private void collectFromDir(File dir, List files, boolean recursive) { if (!dir.isDirectory()) { return; @@ -273,13 +311,24 @@ private void collectFromDir(File dir, List files, boolean recursive) { if (children == null) { return; } + + List featureFilesHere = new ArrayList<>(); + List subDirs = new ArrayList<>(); for (File child : children) { if (child.isFile() && child.getName().endsWith(".feature")) { - files.add(child); + featureFilesHere.add(child); } else if (child.isDirectory() && recursive) { - collectFromDir(child, files, true); + subDirs.add(child); } } + + featureFilesHere.sort(Comparator.comparing(File::getName)); + files.addAll(featureFilesHere); + + subDirs.sort(Comparator.comparing(File::getName)); + for (File subDir : subDirs) { + collectFromDir(subDir, files, true); + } } private void writeAsciidoc( diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java index 2a06c0d..c9718f0 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java @@ -1,5 +1,6 @@ package com.arc_e_tect.gradle.gherkin; +import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.RegularFileProperty; @@ -11,15 +12,16 @@ *
  * gherkinToAsciidoc {
  *     sourceDirs.from('src/test/resources/features')                                // default
- *     includeSubDirs = false                                                        // default
+ *     includeSubDirs = true                                                         // default
  *     outputDir      = layout.buildDirectory.dir('generated-docs')                 // default
  *     outputFileName = 'features.adoc'                                             // default
  *     trackProgress  = false                                                        // default
  *     // glueCodeDirs.from('src/test/java/.../steps')                              // required when trackProgress = true
- *     groupByFeature = false                                                        // default; forced to true whenever trackProgress = true
+ *     groupByFeature = true                                                         // default; forced to true whenever trackProgress = true
  *     // snippetDir  = layout.buildDirectory.dir('generated-docs/features/snippets') // default
  *     // template    = file('templates/report.mustache')                            // optional
  *     // systemUnderTestVersion = 'v1.0.0'          // optional; default: project.version
+ *     indexing       = IndexingMode.OFF                                             // default; requires includeSubDirs = true
  * }
  * 
*/ @@ -59,7 +61,7 @@ public GherkinToAsciidocExtension() {} /** * Whether to recursively scan sub-directories of every configured directory in - * {@link #getSourceDirs()}. Defaults to {@code false}. Forced to {@code true} whenever + * {@link #getSourceDirs()}. Defaults to {@code true}. Forced to {@code true} whenever * {@link #getTrackProgress()} is {@code true}. * * @return mutable boolean property controlling recursive directory scanning @@ -106,7 +108,7 @@ public GherkinToAsciidocExtension() {} /** * Whether to group scenarios by their enclosing {@code Feature} in the generated AsciiDoc, - * instead of a flat list. Defaults to {@code false}. Forced to {@code true} whenever + * instead of a flat list. Defaults to {@code true}. Forced to {@code true} whenever * {@link #getTrackProgress()} is {@code true}, in which case scenarios are grouped by * feature within each of the listed/defined/implemented sections. * @@ -148,4 +150,36 @@ public GherkinToAsciidocExtension() {} * @return mutable string property for the system-under-test version */ public abstract Property getSystemUnderTestVersion(); + + /** + * Whether - and how - to number {@code Feature}/{@code Scenario} titles directly in the + * source {@code .feature} files. Defaults to {@link IndexingMode#OFF}. + * + *
    + *
  • {@link IndexingMode#OFF} - nothing is numbered (default).
  • + *
  • {@link IndexingMode#FEATURE} - every feature is numbered, e.g. + * {@code Feature: 1 - User authentication}.
  • + *
  • {@link IndexingMode#SCENARIO} - every scenario is numbered continuously across all + * feature files, e.g. {@code Scenario: 1 - User logs in}.
  • + *
  • {@link IndexingMode#ALL} - both are numbered, scenarios as + * {@code .}, e.g. {@code Scenario: 1.1 - User logs in}.
  • + *
+ * + *

Feature files are processed in the same order the generated report lists them in: for each + * source directory (directories themselves ordered alphabetically by path when more than one is + * configured), that directory's own feature files first - alphabetically by file name - and only + * then, when {@link #getIncludeSubDirs()} is {@code true}, its sub-directories' files, each + * sub-directory visited the same way, alphabetically by name. Scenario numbers additionally + * follow document order within each file. Changing this property rewrites the source + * {@code .feature} files: any numbering left over from a previous run is removed first, then + * fresh numbering is applied for the new mode - including removing all numbering when set back + * to {@link IndexingMode#OFF}.

+ * + *

Only allowed when {@link #getIncludeSubDirs()} is {@code true}. When + * {@link #getGroupByFeature()} is {@code false}, only {@link IndexingMode#OFF} and + * {@link IndexingMode#SCENARIO} are allowed.

+ * + * @return mutable property for the indexing mode + */ + public abstract Property getIndexing(); } diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java index 48ea024..9f19617 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java @@ -1,5 +1,6 @@ package com.arc_e_tect.gradle.gherkin; +import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.file.FileCollection; @@ -28,9 +29,11 @@ *

Defaults

*
    *
  • Source directory: {@code src/test/resources/features}
  • - *
  • Include sub-directories: {@code false}
  • + *
  • Include sub-directories: {@code true}
  • + *
  • Group scenarios by feature: {@code true}
  • *
  • Output directory: {@code build/generated-docs}
  • *
  • Output file name: {@code features.adoc}
  • + *
  • Indexing: {@code off}
  • *
* *

Multi-project builds

@@ -75,11 +78,13 @@ public void apply(Project project) { ext.getOutputFileName().convention(rootExt.getOutputFileName()); ext.getTemplate().convention(rootExt.getTemplate()); ext.getSystemUnderTestVersion().convention(rootExt.getSystemUnderTestVersion()); + ext.getIndexing().convention(rootExt.getIndexing()); } else { ext.getTrackProgress().convention(false); ext.getOutputFileName().convention(GherkinToAsciidocExtension.DEFAULT_OUTPUT_FILE_NAME); ext.getSystemUnderTestVersion().convention( project.provider(() -> String.valueOf(project.getVersion()))); + ext.getIndexing().convention(IndexingMode.OFF); } // outputDir/snippetDir intentionally always default to this project's own build directory, @@ -93,10 +98,11 @@ public void apply(Project project) { // Enabling trackProgress implies recursive scanning and grouping by feature, unless // includeSubDirs/groupByFeature have been set explicitly - either directly on this // project, or (absent a local trackProgress override) inherited from the root project. + // Both default to true when neither this project nor the root project configures them. Provider inheritedIncludeSubDirs = - rootExt != null ? rootExt.getIncludeSubDirs() : project.provider(() -> false); + rootExt != null ? rootExt.getIncludeSubDirs() : project.provider(() -> true); Provider inheritedGroupByFeature = - rootExt != null ? rootExt.getGroupByFeature() : project.provider(() -> false); + rootExt != null ? rootExt.getGroupByFeature() : project.provider(() -> true); ext.getIncludeSubDirs().convention(ext.getTrackProgress() .flatMap(trackProgress -> trackProgress ? project.provider(() -> true) : inheritedIncludeSubDirs)); ext.getGroupByFeature().convention(ext.getTrackProgress() @@ -115,6 +121,7 @@ public void apply(Project project) { task.getSnippetDir().set(ext.getSnippetDir()); task.getTemplate().set(ext.getTemplate()); task.getSystemUnderTestVersion().set(ext.getSystemUnderTestVersion()); + task.getIndexing().set(ext.getIndexing()); task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); }); } diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java new file mode 100644 index 0000000..90f17e8 --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java @@ -0,0 +1,111 @@ +package com.arc_e_tect.gradle.gherkin.indexing; + +import org.gradle.api.GradleException; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Numbers {@code Feature}/{@code Scenario} titles directly in the source {@code .feature} files + * according to a configured {@link IndexingMode}. + * + *

Every run first strips any numbering left over from a previous run (recognised by the + * {@code - } prefix this class itself adds), then - unless the mode is + * {@link IndexingMode#OFF} - applies fresh numbering. This makes the operation idempotent and + * makes switching between modes (including back to {@code OFF}) simply undo the previous + * numbering rather than requiring any state to be tracked between runs.

+ */ +public class FeatureIndexer { + + private static final Pattern KEYWORD_LINE = + Pattern.compile("^(\\s*)(Feature|Scenario Outline|Scenario):(\\s*)(.*)$"); + private static final Pattern EXISTING_INDEX = Pattern.compile("^\\d+(?:\\.\\d+)? - (.*)$"); + + /** Creates a new {@code FeatureIndexer}. */ + public FeatureIndexer() {} + + /** + * Rewrites every file in {@code featureFiles} in place: strips any {@code Feature}/ + * {@code Scenario} numbering added by a previous run, then applies numbering per + * {@code mode}. Files are numbered in the order they appear in {@code featureFiles} - the + * caller is responsible for ordering that list the way numbers should be assigned. A file is + * only rewritten on disk when its content actually changes. + * + * @param featureFiles the feature files collected for this run, in the order to number them in + * @param mode the indexing mode to apply + */ + public void reindex(List featureFiles, IndexingMode mode) { + int featureNumber = 0; + int scenarioNumber = 0; + for (File featureFile : featureFiles) { + featureNumber++; + int scenarioInFeature = 0; + List lines = readLines(featureFile); + List rewritten = new ArrayList<>(lines.size()); + boolean changed = false; + + for (String line : lines) { + Matcher matcher = KEYWORD_LINE.matcher(line); + if (!matcher.matches()) { + rewritten.add(line); + continue; + } + + String indent = matcher.group(1); + String keyword = matcher.group(2); + String gap = matcher.group(3); + String name = stripExistingIndex(matcher.group(4)); + + String newName; + if ("Feature".equals(keyword)) { + newName = (mode == IndexingMode.FEATURE || mode == IndexingMode.ALL) + ? featureNumber + " - " + name + : name; + } else if (mode == IndexingMode.SCENARIO) { + scenarioNumber++; + newName = scenarioNumber + " - " + name; + } else if (mode == IndexingMode.ALL) { + scenarioInFeature++; + newName = featureNumber + "." + scenarioInFeature + " - " + name; + } else { + newName = name; + } + + String newLine = indent + keyword + ":" + gap + newName; + changed |= !newLine.equals(line); + rewritten.add(newLine); + } + + if (changed) { + writeLines(featureFile, rewritten); + } + } + } + + private String stripExistingIndex(String name) { + Matcher matcher = EXISTING_INDEX.matcher(name); + return matcher.matches() ? matcher.group(1) : name; + } + + private List readLines(File file) { + try { + return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new GradleException("gherkinToAsciidoc: could not read feature file: " + file, e); + } + } + + private void writeLines(File file, List lines) { + try { + Files.write(file.toPath(), lines, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new GradleException("gherkinToAsciidoc: could not update feature file: " + file, e); + } + } +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/IndexingMode.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/IndexingMode.java new file mode 100644 index 0000000..6eb88e8 --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/IndexingMode.java @@ -0,0 +1,31 @@ +package com.arc_e_tect.gradle.gherkin.indexing; + +/** + * Controls whether {@code Feature}/{@code Scenario} titles are numbered directly in the source + * {@code .feature} files, as configured via the {@code indexing} DSL property. + */ +public enum IndexingMode { + + /** Nothing is numbered. Any numbering left over from a previous run is removed. */ + OFF, + + /** + * Every feature is numbered, in the order its feature file is processed in - see + * {@code GherkinToAsciidocExtension#getIndexing()} for exactly what that order is - e.g. + * {@code Feature: 1 - User authentication}. + */ + FEATURE, + + /** + * Every scenario is numbered, continuously across all feature files, in the same file + * processing order as {@link #FEATURE}, e.g. {@code Scenario: 1 - User logs in}. + */ + SCENARIO, + + /** + * Both features and scenarios are numbered. Scenarios are numbered per feature as + * {@code .}, e.g. {@code Scenario: 1.1 - User logs in} + * within {@code Feature: 1 - User authentication}. + */ + ALL +} diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java index 48beab5..54719aa 100644 --- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java @@ -1,5 +1,6 @@ package com.arc_e_tect.gradle.gherkin; +import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode; import org.gradle.api.Project; import org.gradle.testfixtures.ProjectBuilder; import org.junit.jupiter.api.DisplayName; @@ -228,6 +229,27 @@ void subProjectOwnGlueCodeDirsOverridesRootCascade() { assertThat(task(sub).getGlueCodeDirs().getFiles()).containsExactly(ownDir); } + @Test + @DisplayName("sub-project without its own configuration inherits indexing from the root project") + void subProjectInheritsIndexingFromRoot() { + Project root = rootProject(); + extension(root).getIndexing().set(IndexingMode.ALL); + Project sub = subProject(root, "sub"); + + assertThat(extension(sub).getIndexing().get()).isEqualTo(IndexingMode.ALL); + } + + @Test + @DisplayName("sub-project's own indexing takes precedence over the root project's") + void subProjectIndexingOverridesRoot() { + Project root = rootProject(); + extension(root).getIndexing().set(IndexingMode.ALL); + Project sub = subProject(root, "sub"); + extension(sub).getIndexing().set(IndexingMode.OFF); + + assertThat(extension(sub).getIndexing().get()).isEqualTo(IndexingMode.OFF); + } + // --- helpers --- private Project rootProject() { diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java index 591f0cb..9699166 100644 --- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java @@ -1,5 +1,6 @@ package com.arc_e_tect.gradle.gherkin; +import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode; import org.gradle.api.Project; import org.gradle.testfixtures.ProjectBuilder; import org.junit.jupiter.api.DisplayName; @@ -31,12 +32,12 @@ void registersGenerateFeatureDocsTask() { } @Test - @DisplayName("extension default: includeSubDirs is false") - void extensionDefaultIncludeSubDirsIsFalse() { + @DisplayName("extension default: includeSubDirs is true") + void extensionDefaultIncludeSubDirsIsTrue() { Project project = projectWithPlugin(); GherkinToAsciidocExtension ext = extension(project); - assertThat(ext.getIncludeSubDirs().get()).isFalse(); + assertThat(ext.getIncludeSubDirs().get()).isTrue(); } @Test @@ -69,12 +70,12 @@ void includeSubDirsDefaultsToTrueWhenTrackProgressEnabled() { } @Test - @DisplayName("extension default: groupByFeature is false") - void extensionDefaultGroupByFeatureIsFalse() { + @DisplayName("extension default: groupByFeature is true") + void extensionDefaultGroupByFeatureIsTrue() { Project project = projectWithPlugin(); GherkinToAsciidocExtension ext = extension(project); - assertThat(ext.getGroupByFeature().get()).isFalse(); + assertThat(ext.getGroupByFeature().get()).isTrue(); } @Test @@ -148,6 +149,15 @@ void extensionDefaultOutputDirIsGeneratedDocs() { .endsWith("build" + File.separator + "generated-docs"); } + @Test + @DisplayName("extension default: indexing is off") + void extensionDefaultIndexingIsOff() { + Project project = projectWithPlugin(); + GherkinToAsciidocExtension ext = extension(project); + + assertThat(ext.getIndexing().get()).isEqualTo(IndexingMode.OFF); + } + @Test @DisplayName("generates features.adoc from a flat source directory") void generatesAsciidocFromFlatDirectory() throws IOException { @@ -188,6 +198,7 @@ void generatesAsciidocFromMultipleSourceDirectories() throws IOException { GenerateFeatureDocsTask task = task(project); task.getSourceDirs().from(firstDir, secondDir); + task.getGroupByFeature().set(false); task.getOutputDir().set(outputDir); task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); task.generate(); @@ -240,6 +251,7 @@ void generatesAsciidocFromSingleFile() throws IOException { GenerateFeatureDocsTask task = task(project); task.getSourceFile().set(singleFile); + task.getIncludeSubDirs().set(false); task.getOutputDir().set(outputDir); task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); task.generate(); @@ -276,6 +288,89 @@ void generatesAsciidocFromRecursiveDirectory() throws IOException { .contains("* Scenario: Sub scenario"); } + @Test + @DisplayName("processes a directory's own feature files before descending into its sub-directories, " + + "even when a sub-directory would sort first alphabetically by path") + void processesOwnDirectoryFilesBeforeSubDirectories() throws IOException { + Project project = projectWithPlugin(); + File rootDir = new File(tempDir.toFile(), "features"); + // "sub" sorts before "z.feature" if compared as plain path strings, but the own-directory + // file must still be processed first: only descending into sub-directories afterwards. + File subDir = new File(rootDir, "sub"); + subDir.mkdirs(); + writeFeatureFile(rootDir, "z.feature", + "Feature: Z Feature\n\n Scenario: Z scenario\n Given z\n"); + writeFeatureFile(subDir, "a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(rootDir); + task.getOutputDir().set(outputDir); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + List lines = Files.readAllLines(new File(outputDir, "features.adoc").toPath()); + assertThat(lines).containsSubsequence("== Z Feature", "== A Feature"); + } + + @Test + @DisplayName("indexing numbers a directory's own feature files before its sub-directories' files") + void indexingNumbersOwnDirectoryFilesBeforeSubDirectories() throws IOException { + Project project = projectWithPlugin(); + File rootDir = new File(tempDir.toFile(), "features"); + File subDir = new File(rootDir, "sub"); + subDir.mkdirs(); + writeFeatureFile(rootDir, "z.feature", + "Feature: Z Feature\n\n Scenario: Z scenario\n Given z\n"); + writeFeatureFile(subDir, "a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(rootDir); + task.getIndexing().set(IndexingMode.FEATURE); + task.getOutputDir().set(outputDir); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + assertThat(Files.readString(rootDir.toPath().resolve("z.feature"))) + .contains("Feature: 1 - Z Feature"); + assertThat(Files.readString(subDir.toPath().resolve("a.feature"))) + .contains("Feature: 2 - A Feature"); + } + + @Test + @DisplayName("multiple sourceDirs are processed alphabetically by path, regardless of configuration order") + void multipleSourceDirsProcessedAlphabeticallyByPath() throws IOException { + Project project = projectWithPlugin(); + File featuresAuth = new File(tempDir.toFile(), "features-auth"); + File featuresBilling = new File(tempDir.toFile(), "features-billing"); + featuresAuth.mkdirs(); + featuresBilling.mkdirs(); + writeFeatureFile(featuresAuth, "authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + writeFeatureFile(featuresBilling, "invoice.feature", + "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); + + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask task = task(project); + // Configured out of alphabetical order: billing before auth. + task.getSourceDirs().from(featuresBilling, featuresAuth); + task.getIndexing().set(IndexingMode.FEATURE); + task.getOutputDir().set(outputDir); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + assertThat(Files.readString(featuresAuth.toPath().resolve("authentication.feature"))) + .contains("Feature: 1 - User authentication"); + assertThat(Files.readString(featuresBilling.toPath().resolve("invoice.feature"))) + .contains("Feature: 2 - Invoice payment"); + } + @Test @DisplayName("throws GradleException when both sourceDirs and sourceFile are configured") void throwsWhenBothSourceDirsAndSourceFileAreSet() throws IOException { @@ -324,6 +419,7 @@ void throwsWhenTrackProgressEnabledWithoutSourceDirs() throws IOException { GenerateFeatureDocsTask task = task(project); task.getSourceFile().set(file); + task.getIncludeSubDirs().set(false); task.getTrackProgress().set(true); task.getGlueCodeDirs().from(glueCodeDir); task.getOutputDir().set(new File(tempDir.toFile(), "output")); @@ -653,6 +749,186 @@ void outputFileDeclaresTableOfContents() throws IOException { "= Feature Scenarios", ":toc:", ":toclevels: 2", ""); } + @Test + @DisplayName("throws GradleException when indexing is enabled but includeSubDirs is false") + void throwsWhenIndexingEnabledWithoutIncludeSubDirs() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", "Feature: Sample\n\n Scenario: A scenario\n Given g\n"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getIncludeSubDirs().set(false); + task.getIndexing().set(IndexingMode.SCENARIO); + task.getOutputDir().set(new File(tempDir.toFile(), "output")); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + + assertThatThrownBy(task::generate) + .isInstanceOf(org.gradle.api.GradleException.class) + .hasMessageContaining("indexing can only be used when includeSubDirs is true"); + } + + @Test + @DisplayName("throws GradleException when indexing is FEATURE and groupByFeature is false") + void throwsWhenIndexingFeatureAndGroupByFeatureFalse() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", "Feature: Sample\n\n Scenario: A scenario\n Given g\n"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getGroupByFeature().set(false); + task.getIndexing().set(IndexingMode.FEATURE); + task.getOutputDir().set(new File(tempDir.toFile(), "output")); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + + assertThatThrownBy(task::generate) + .isInstanceOf(org.gradle.api.GradleException.class) + .hasMessageContaining("when groupByFeature is false, indexing can only be 'off' or 'scenario'"); + } + + @Test + @DisplayName("throws GradleException when indexing is ALL and groupByFeature is false") + void throwsWhenIndexingAllAndGroupByFeatureFalse() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", "Feature: Sample\n\n Scenario: A scenario\n Given g\n"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getGroupByFeature().set(false); + task.getIndexing().set(IndexingMode.ALL); + task.getOutputDir().set(new File(tempDir.toFile(), "output")); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + + assertThatThrownBy(task::generate) + .isInstanceOf(org.gradle.api.GradleException.class) + .hasMessageContaining("when groupByFeature is false, indexing can only be 'off' or 'scenario'"); + } + + @Test + @DisplayName("indexing SCENARIO is allowed when groupByFeature is false") + void indexingScenarioAllowedWithGroupByFeatureFalse() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", "Feature: Sample\n\n Scenario: A scenario\n Given g\n"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getGroupByFeature().set(false); + task.getIndexing().set(IndexingMode.SCENARIO); + task.getOutputDir().set(new File(tempDir.toFile(), "output")); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + assertThat(Files.readString(featuresDir.toPath().resolve("sample.feature"))) + .contains("Scenario: 1 - A scenario"); + } + + @Test + @DisplayName("indexing FEATURE numbers features alphabetically by file name across source directories " + + "and is reflected in the generated report") + void indexingFeatureNumbersFeaturesAndUpdatesReport() throws IOException { + Project project = projectWithPlugin(); + File authDir = new File(tempDir.toFile(), "features-auth"); + File billingDir = new File(tempDir.toFile(), "features-billing"); + authDir.mkdirs(); + billingDir.mkdirs(); + writeFeatureFile(authDir, "authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + writeFeatureFile(billingDir, "invoice.feature", + "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); + + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(authDir, billingDir); + task.getIndexing().set(IndexingMode.FEATURE); + task.getOutputDir().set(outputDir); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + assertThat(Files.readString(authDir.toPath().resolve("authentication.feature"))) + .contains("Feature: 1 - User authentication"); + assertThat(Files.readString(billingDir.toPath().resolve("invoice.feature"))) + .contains("Feature: 2 - Invoice payment"); + + String content = Files.readString(new File(outputDir, "features.adoc").toPath()); + assertThat(content) + .contains("== 1 - User authentication") + .contains("== 2 - Invoice payment"); + } + + @Test + @DisplayName("indexing ALL numbers scenarios per feature and is reflected in the generated report") + void indexingAllNumbersScenariosPerFeatureAndUpdatesReport() throws IOException { + Project project = projectWithPlugin(); + File authDir = new File(tempDir.toFile(), "features-auth"); + authDir.mkdirs(); + writeFeatureFile(authDir, "authentication.feature", """ + Feature: User authentication + + Scenario: User logs in + Given a user + + Scenario: User resets password + Given a user + """); + + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(authDir); + task.getIndexing().set(IndexingMode.ALL); + task.getOutputDir().set(outputDir); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + String content = Files.readString(new File(outputDir, "features.adoc").toPath()); + assertThat(content) + .contains("== 1 - User authentication") + .contains("* Scenario: 1.1 - User logs in") + .contains("* Scenario: 1.2 - User resets password"); + } + + @Test + @DisplayName("changing indexing from ALL to OFF on a subsequent run removes the numbering") + void changingIndexingToOffRemovesNumberingOnNextRun() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", + "Feature: Sample\n\n Scenario: A scenario\n Given g\n"); + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask firstRun = task(project); + firstRun.getSourceDirs().from(featuresDir); + firstRun.getIndexing().set(IndexingMode.ALL); + firstRun.getOutputDir().set(outputDir); + firstRun.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + firstRun.generate(); + assertThat(Files.readString(featuresDir.toPath().resolve("sample.feature"))) + .contains("Feature: 1 - Sample") + .contains("Scenario: 1.1 - A scenario"); + + GenerateFeatureDocsTask secondRun = task(project); + secondRun.getSourceDirs().from(featuresDir); + secondRun.getIndexing().set(IndexingMode.OFF); + secondRun.getOutputDir().set(outputDir); + secondRun.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + secondRun.generate(); + + assertThat(Files.readString(featuresDir.toPath().resolve("sample.feature"))) + .contains("Feature: Sample") + .contains("Scenario: A scenario") + .doesNotContain("1 -") + .doesNotContain("1.1 -"); + } + // --- helpers --- private Project projectWithPlugin() { diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java new file mode 100644 index 0000000..e1daaf7 --- /dev/null +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java @@ -0,0 +1,234 @@ +package com.arc_e_tect.gradle.gherkin.indexing; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("FeatureIndexer") +class FeatureIndexerTest { + + @TempDir + Path tempDir; + + private final FeatureIndexer indexer = new FeatureIndexer(); + + @Test + @DisplayName("mode OFF leaves feature and scenario titles untouched") + void offModeLeavesTitlesUntouched() throws IOException { + File file = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + + indexer.reindex(List.of(file), IndexingMode.OFF); + + assertThat(content(file)) + .contains("Feature: User authentication") + .contains("Scenario: User logs in"); + } + + @Test + @DisplayName("mode FEATURE numbers features in the order given, leaves scenarios untouched") + void featureModeNumbersFeaturesInGivenOrder() throws IOException { + File auth = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + File invoice = writeFeature("invoice.feature", + "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); + + indexer.reindex(List.of(auth, invoice), IndexingMode.FEATURE); + + assertThat(content(auth)) + .contains("Feature: 1 - User authentication") + .contains("Scenario: User logs in"); + assertThat(content(invoice)) + .contains("Feature: 2 - Invoice payment") + .contains("Scenario: User pays an invoice"); + } + + @Test + @DisplayName("mode SCENARIO numbers scenarios continuously across files, leaves features untouched") + void scenarioModeNumbersScenariosContinuously() throws IOException { + File auth = writeFeature("authentication.feature", """ + Feature: User authentication + + Scenario: User logs in + Given a user + + Scenario: User resets password + Given a user + """); + File invoice = writeFeature("invoice.feature", + "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); + + indexer.reindex(List.of(auth, invoice), IndexingMode.SCENARIO); + + assertThat(content(auth)) + .contains("Feature: User authentication") + .contains("Scenario: 1 - User logs in") + .contains("Scenario: 2 - User resets password"); + assertThat(content(invoice)) + .contains("Feature: Invoice payment") + .contains("Scenario: 3 - User pays an invoice"); + } + + @Test + @DisplayName("mode ALL numbers features and numbers scenarios per feature as featureNumber.scenarioNumber") + void allModeNumbersFeaturesAndScenariosPerFeature() throws IOException { + File auth = writeFeature("authentication.feature", """ + Feature: User authentication + + Scenario: User logs in + Given a user + + Scenario: User resets password + Given a user + """); + File invoice = writeFeature("invoice.feature", + "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); + + indexer.reindex(List.of(auth, invoice), IndexingMode.ALL); + + assertThat(content(auth)) + .contains("Feature: 1 - User authentication") + .contains("Scenario: 1.1 - User logs in") + .contains("Scenario: 1.2 - User resets password"); + assertThat(content(invoice)) + .contains("Feature: 2 - Invoice payment") + .contains("Scenario: 2.1 - User pays an invoice"); + } + + @Test + @DisplayName("numbers files in the order given, not re-sorted alphabetically") + void numbersFilesInGivenOrderNotAlphabetically() throws IOException { + File zFile = writeFeature("z.feature", + "Feature: Z Feature\n\n Scenario: Z scenario\n Given z\n"); + File aFile = writeFeature("a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + // Given in z-then-a order: the caller (not the indexer) is responsible for ordering. + indexer.reindex(List.of(zFile, aFile), IndexingMode.FEATURE); + + assertThat(content(zFile)).contains("Feature: 1 - Z Feature"); + assertThat(content(aFile)).contains("Feature: 2 - A Feature"); + } + + @Test + @DisplayName("numbers Scenario Outline the same as Scenario") + void numbersScenarioOutline() throws IOException { + File file = writeFeature("outline.feature", """ + Feature: Sample + + Scenario Outline: User logs in with + Given a "" user + + Examples: + | role | + | admin | + """); + + indexer.reindex(List.of(file), IndexingMode.ALL); + + assertThat(content(file)).contains("Scenario Outline: 1.1 - User logs in with "); + } + + @Test + @DisplayName("numbers scenarios nested inside a Rule block, preserving their indentation") + void numbersScenariosInsideRule() throws IOException { + File file = writeFeature("rules.feature", """ + Feature: Rule-Based Scenarios + + Rule: Registered users can access premium content + + Scenario: Premium user views protected page + Given a premium user + """); + + indexer.reindex(List.of(file), IndexingMode.SCENARIO); + + assertThat(content(file)).contains(" Scenario: 1 - Premium user views protected page"); + } + + @Test + @DisplayName("switching from ALL to OFF removes all numbering") + void switchingToOffRemovesNumbering() throws IOException { + File auth = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + File invoice = writeFeature("invoice.feature", + "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); + indexer.reindex(List.of(auth, invoice), IndexingMode.ALL); + + indexer.reindex(List.of(auth, invoice), IndexingMode.OFF); + + assertThat(content(auth)) + .contains("Feature: User authentication") + .contains("Scenario: User logs in") + .doesNotContain("1 -") + .doesNotContain("1.1 -"); + assertThat(content(invoice)) + .contains("Feature: Invoice payment") + .contains("Scenario: User pays an invoice"); + } + + @Test + @DisplayName("switching from SCENARIO to FEATURE removes scenario numbers and adds feature numbers") + void switchingModesReplacesNumbering() throws IOException { + File auth = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + indexer.reindex(List.of(auth), IndexingMode.SCENARIO); + assertThat(content(auth)).contains("Scenario: 1 - User logs in"); + + indexer.reindex(List.of(auth), IndexingMode.FEATURE); + + assertThat(content(auth)) + .contains("Feature: 1 - User authentication") + .contains("Scenario: User logs in") + .doesNotContain("Scenario: 1 -"); + } + + @Test + @DisplayName("re-running the same mode is idempotent and does not change file content") + void reindexingWithSameModeIsIdempotent() throws IOException { + File auth = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + indexer.reindex(List.of(auth), IndexingMode.ALL); + String firstPass = content(auth); + + indexer.reindex(List.of(auth), IndexingMode.ALL); + + assertThat(content(auth)).isEqualTo(firstPass); + } + + @Test + @DisplayName("does not renumber unrelated lines that merely contain the word Scenario") + void doesNotTouchUnrelatedLines() throws IOException { + File file = writeFeature("sample.feature", """ + Feature: Sample + + Scenario: User logs in + Given a user with role "Scenario: not a keyword" + """); + + indexer.reindex(List.of(file), IndexingMode.SCENARIO); + + assertThat(content(file)) + .contains("Scenario: 1 - User logs in") + .contains("Given a user with role \"Scenario: not a keyword\""); + } + + private File writeFeature(String name, String content) throws IOException { + File file = tempDir.resolve(name).toFile(); + Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); + return file; + } + + private String content(File file) throws IOException { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } +}