From ce4ea0509e9f86d87d618c71ae782b2e1ac9891d Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Sat, 25 Jul 2026 01:05:45 +0400 Subject: [PATCH 1/3] feat(gherkin-to-asciidoc): write report snippets and support custom Mustache templates Adds two DSL properties, consulted only when trackProgress is true: - snippetDir (default: build/generated-docs/features/snippets): every run now writes listed.adoc/defined.adoc/implemented.adoc snippet files containing exactly the scenario bullets that status's section would otherwise embed. When groupByFeature is also true, a status with at least one scenario writes one snippet file per feature instead, under //.adoc. Snippets are written unconditionally, regardless of whether a template is configured. - template (optional): a Mustache file used to render the report so that it references the snippets via include:: directives instead of embedding their content verbatim. Without one, the report looks exactly as it did before this change. groupByFeature is now also genuinely respected in tracking mode instead of being unconditionally forced on internally: it still defaults to true whenever trackProgress is true (same as before), but can be explicitly set to false to get a flat, non-grouped progress report - needed to produce flat snippets/reports at all. New classes: FeatureNameFormatter (camelCase directory naming), SnippetWriter (+ FeatureSnippet/StatusSnippets), ProgressReportOptions, ReportTemplateRenderer (Mustache, via com.github.spullara.mustache.java), ReportText (explanatory copy shared between the default writer and the template context), StatusSummary. ProgressReportWriter now orchestrates classification, snippet writing, and dispatches to either the template renderer or the existing verbatim writer. Includes two reference templates (test resources templates/grouped.mustache and templates/flat.mustache, also documented in the plugin README) verified by DefaultEquivalentTemplatesTest to reproduce - once their include:: directives are resolved - byte-identical output to the report with no template configured, for both grouped and flat modes. Co-Authored-By: Claude Sonnet 5 --- gherkin-to-asciidoc/README.adoc | 178 ++++++++++++++++ gherkin-to-asciidoc/build.gradle | 1 + gherkin-to-asciidoc/gradle/libs.versions.toml | 2 + .../gherkin/GenerateFeatureDocsTask.java | 32 ++- .../gherkin/GherkinToAsciidocExtension.java | 30 +++ .../gherkin/GherkinToAsciidocPlugin.java | 4 + .../progress/ProgressReportOptions.java | 17 ++ .../progress/ProgressReportWriter.java | 192 +++++++++++------- .../progress/ReportTemplateRenderer.java | 102 ++++++++++ .../gradle/gherkin/progress/ReportText.java | 24 +++ .../gherkin/progress/StatusSummary.java | 21 ++ .../gherkin/snippet/FeatureNameFormatter.java | 30 +++ .../gherkin/snippet/FeatureSnippet.java | 12 ++ .../gradle/gherkin/snippet/SnippetWriter.java | 77 +++++++ .../gherkin/snippet/StatusSnippets.java | 17 ++ .../gherkin/GherkinToAsciidocPluginTest.java | 105 ++++++++++ .../DefaultEquivalentTemplatesTest.java | 127 ++++++++++++ .../progress/ProgressReportWriterTest.java | 91 +++++++-- .../progress/ReportTemplateRendererTest.java | 170 ++++++++++++++++ .../snippet/FeatureNameFormatterTest.java | 40 ++++ .../gherkin/snippet/SnippetWriterTest.java | 81 ++++++++ .../test/resources/templates/flat.mustache | 39 ++++ .../test/resources/templates/grouped.mustache | 47 +++++ 23 files changed, 1349 insertions(+), 90 deletions(-) create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportOptions.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRenderer.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportText.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/StatusSummary.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatter.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureSnippet.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriter.java create mode 100644 gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/StatusSnippets.java create mode 100644 gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/DefaultEquivalentTemplatesTest.java create mode 100644 gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRendererTest.java create mode 100644 gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatterTest.java create mode 100644 gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriterTest.java create mode 100644 gherkin-to-asciidoc/src/test/resources/templates/flat.mustache create mode 100644 gherkin-to-asciidoc/src/test/resources/templates/grouped.mustache diff --git a/gherkin-to-asciidoc/README.adoc b/gherkin-to-asciidoc/README.adoc index 93338d1..895df54 100644 --- a/gherkin-to-asciidoc/README.adoc +++ b/gherkin-to-asciidoc/README.adoc @@ -103,6 +103,17 @@ gherkinToAsciidoc { // (see "Grouping by Feature" below). Forced to true whenever trackProgress is true. // Default: false groupByFeature = false + + // Directory that the listed.adoc/defined.adoc/implemented.adoc report snippets are written + // to when trackProgress is true (see "Report Snippets and Custom Templates" below). + // Default: "$buildDir/generated-docs/features/snippets" + // snippetDir = file("$buildDir/generated-docs/features/snippets") + + // Optional Mustache template used to render the report so it references the snippets via + // include:: directives instead of embedding their content verbatim. Only consulted when + // trackProgress is true. When not set, the report looks exactly like it does without this + // property - the snippets are still written either way. + // template = file('templates/report.mustache') } ---- @@ -117,6 +128,8 @@ gherkinToAsciidoc { trackProgress.set(false) // glueCodeDirs.from("src/test/java/com/example/steps") groupByFeature.set(false) + // snippetDir.set(layout.buildDirectory.dir("generated-docs/features/snippets")) + // template.set(file("templates/report.mustache")) } ---- @@ -139,6 +152,10 @@ Requires `sourceDirs` and `glueCodeDirs`; implies `includeSubDirs = true`. Required when `trackProgress` is `true`. | `groupByFeature`| Boolean | `false` | When `true`, groups scenarios under their enclosing `Feature` instead of a flat list. Forced to `true` whenever `trackProgress` is `true`. +| `snippetDir` | Directory | `$buildDir/generated-docs/features/snippets` | Directory that the `listed.adoc`/`defined.adoc`/`implemented.adoc` report snippets are written to. +Only used when `trackProgress` is `true`. +| `template` | File | — | Optional Mustache template used to render the report so it references the snippets via `include::` directives instead of embedding their content verbatim. +Only consulted when `trackProgress` is `true`. |=== [IMPORTANT] @@ -151,6 +168,8 @@ Setting both will cause the `generateFeatureDocs` task to fail with a descriptiv `trackProgress` can only be enabled when `sourceDirs` is configured, and requires `glueCodeDirs` to be set. Enabling it implies `includeSubDirs = true` and `groupByFeature = true`, regardless of those properties' own configured values. + +`snippetDir` and `template` are only consulted when `trackProgress` is `true`; both are ignored otherwise. ==== == Running the Task @@ -396,6 +415,165 @@ above, so that one step does have matching glue code. Its other two steps don't needs matching glue code for a scenario to count as `implemented`. ==== +== Report Snippets and Custom Templates + +Whenever `trackProgress` is `true`, the plugin writes three snippet files to `snippetDir` - +`listed.adoc`, `defined.adoc`, and `implemented.adoc` - containing exactly the scenario bullets +that status's section would otherwise embed. These are written unconditionally, regardless of +whether `template` is configured. + +When `groupByFeature` is also `true` (the default whenever `trackProgress` is `true`), a status +with at least one scenario writes one snippet file *per feature* instead of a single flat file, at +`//.adoc` - e.g. `Feature: User authentication` becomes +the directory `userAuthentication`. A status with no scenarios at all still writes a single flat +`/.adoc` containing `_None._`, since there's no feature to group under. + +By itself, this doesn't change the generated report: without a `template`, scenario titles are still +embedded directly, exactly as described above - the snippet files are simply written alongside it. + +=== Rendering the Report From a Template + +Setting `template` to a https://github.com/spullara/mustache.java[Mustache] file switches the report +itself from embedding scenario titles verbatim to referencing the snippets via `include::` directives. +The plugin renders `template` with the following context: + +[cols="1,3",options="header"] +|=== +| Variable | Description + +| `intro` +| The one-sentence description of what the document contains. + +| `legend` +| List of `\{status, meaning}` - the three rows of the status legend table. + +| `summary` +| List of `\{status, count, percentage}` - the three rows of the progress summary table. + +| `sections` +| List of `\{status, blurb, features, snippet}`, one per status, in Listed/Defined/Implemented order. + +`features` is a list of `\{title, snippet}` when `groupByFeature` is `true` and the status has at +least one scenario; empty otherwise, in which case `snippet` holds the flat fallback path instead. + +Every `snippet` path is already relative to the output file's directory, so a plain ++include::{{{snippet}}}[]+ resolves correctly. +|=== + +[IMPORTANT] +==== +Use triple-mustache (+{{{value}}}+) or +{{&value}}+ for every substitution. Plain +{{value}}+ +HTML-escapes its output (`<`, `>`, `&`, `'`, `"`), which would corrupt AsciiDoc syntax and any +scenario or feature title containing those characters. +==== + +=== Reference Templates + +The following two templates reproduce - once an AsciiDoc processor resolves their `include::` +directives - *exactly* the same document as not configuring a `template` at all (verified by the +plugin's own test suite). Use them as a starting point for a custom template. + +.`templates/flat.mustache` - for `groupByFeature = false` +[source,mustache] +---- += Feature Scenarios + +{{{intro}}} + +Every scenario is classified as exactly one of: + +[cols="1,3",options="header"] +|=== +| Status | Meaning + +{{#legend}} +| {{{status}}} +| {{{meaning}}} + +{{/legend}} +|=== + +== Progress Summary + +[cols="1,1,1",options="header"] +|=== +| Status | Count | Percentage + +{{#summary}} +| {{{status}}} +| {{count}} +| {{percentage}}% + +{{/summary}} +|=== + +{{#sections}} +== {{{status}}} + +{{{blurb}}} + +include::{{{snippet}}}[] + +{{/sections}} +---- + +.`templates/grouped.mustache` - for `groupByFeature = true` +[source,mustache] +---- += Feature Scenarios + +{{{intro}}} + +Every scenario is classified as exactly one of: + +[cols="1,3",options="header"] +|=== +| Status | Meaning + +{{#legend}} +| {{{status}}} +| {{{meaning}}} + +{{/legend}} +|=== + +== Progress Summary + +[cols="1,1,1",options="header"] +|=== +| Status | Count | Percentage + +{{#summary}} +| {{{status}}} +| {{count}} +| {{percentage}}% + +{{/summary}} +|=== + +{{#sections}} +== {{{status}}} + +{{{blurb}}} + +{{#features}} +=== {{{title}}} + +include::{{{snippet}}}[] + +{{/features}} +{{^features}} +include::{{{snippet}}}[] + +{{/features}} +{{/sections}} +---- + +See the +link:../examples/gherkin-to-asciidoc/snippet-templates/README.adoc[snippet-templates example] +for these two templates in use, alongside their non-templated equivalents, generated from the same +feature/glue content. + == License This plugin is released under the MIT License. diff --git a/gherkin-to-asciidoc/build.gradle b/gherkin-to-asciidoc/build.gradle index 3e8237a..3e78c8b 100644 --- a/gherkin-to-asciidoc/build.gradle +++ b/gherkin-to-asciidoc/build.gradle @@ -38,6 +38,7 @@ dependencyCheck { dependencies { implementation libs.gherkin implementation libs.cucumber.expressions + implementation libs.mustache testImplementation libs.junit.jupiter testImplementation libs.assertj.core diff --git a/gherkin-to-asciidoc/gradle/libs.versions.toml b/gherkin-to-asciidoc/gradle/libs.versions.toml index bd6a8b2..55d6b04 100644 --- a/gherkin-to-asciidoc/gradle/libs.versions.toml +++ b/gherkin-to-asciidoc/gradle/libs.versions.toml @@ -11,6 +11,7 @@ owasp-dependency-check = "12.2.2" gherkin = "41.0.0" cucumber-expressions = "18.0.1" +mustache = "0.9.14" junit-jupiter = "6.1.2" junit-platform = "6.1.2" assertj = "3.27.7" @@ -20,6 +21,7 @@ plugin-publish = "2.1.1" gherkin = { module = "io.cucumber:gherkin", version.ref = "gherkin" } cucumber-expressions = { module = "io.cucumber:cucumber-expressions", version.ref = "cucumber-expressions" } +mustache = { module = "com.github.spullara.mustache.java:compiler", version.ref = "mustache" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } 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 f3c86e0..d0f24e2 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 @@ -4,6 +4,7 @@ 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; +import com.arc_e_tect.gradle.gherkin.progress.ProgressReportOptions; import com.arc_e_tect.gradle.gherkin.progress.ProgressReportWriter; import io.cucumber.cucumberexpressions.Expression; import org.gradle.api.DefaultTask; @@ -116,14 +117,36 @@ public abstract class GenerateFeatureDocsTask extends DefaultTask { public abstract ConfigurableFileCollection getGlueCodeDirs(); /** - * Whether to group scenarios by their enclosing {@code Feature} instead of a flat list. - * Ignored (always treated as {@code true}) when {@link #getTrackProgress()} is {@code true}. + * Whether to group scenarios by their enclosing {@code Feature} instead of a flat list. When + * {@link #getTrackProgress()} is {@code true}, grouping additionally splits report snippets by + * feature (see {@link #getSnippetDir()}). * * @return mutable boolean property controlling grouping by feature */ @Input public abstract Property getGroupByFeature(); + /** + * Directory that report snippets ({@code listed.adoc}/{@code defined.adoc}/{@code implemented.adoc}) + * are written to when {@link #getTrackProgress()} is {@code true}. + * + * @return mutable directory property for the snippet output directory + */ + @OutputDirectory + public abstract DirectoryProperty getSnippetDir(); + + /** + * Optional Mustache template used to render the report so that it references the snippets via + * {@code include::} directives instead of embedding their content verbatim. Only consulted + * when {@link #getTrackProgress()} is {@code true}; ignored otherwise. + * + * @return mutable file property for the Mustache template file + */ + @Optional + @InputFile + @PathSensitive(PathSensitivity.RELATIVE) + public abstract RegularFileProperty getTemplate(); + /** * Root directory of the project, used to resolve the default source directory * when neither {@link #getSourceDirs()} nor {@link #getSourceFile()} is set. @@ -196,7 +219,10 @@ public void generate() { if (trackProgress) { List glueCode = scanGlueCode(); - new ProgressReportWriter().write(outputFile, scenarios, glueCode); + File template = getTemplate().isPresent() ? getTemplate().getAsFile().get() : null; + ProgressReportOptions options = new ProgressReportOptions( + getGroupByFeature().get(), getSnippetDir().getAsFile().get(), template); + new ProgressReportWriter().write(outputFile, scenarios, glueCode, options); } else { writeAsciidoc(outputFile, scenarios, getGroupByFeature().get()); } 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 81aca3e..1839a64 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 @@ -17,6 +17,8 @@ * trackProgress = false // default * // glueCodeDirs.from('src/test/java/.../steps') // required when trackProgress = true * groupByFeature = false // default; forced to true whenever trackProgress = true + * // snippetDir = layout.buildDirectory.dir('generated-docs/features/snippets') // default + * // template = file('templates/report.mustache') // optional * } * */ @@ -34,6 +36,9 @@ public GherkinToAsciidocExtension() {} /** Default name of the generated AsciiDoc output file. */ public static final String DEFAULT_OUTPUT_FILE_NAME = "features.adoc"; + /** Default relative path of the directory report snippets are written to. */ + public static final String DEFAULT_SNIPPET_DIR = "generated-docs/features/snippets"; + /** * Source directories that contain the {@code .feature} files to process. One or more * directories may be configured, e.g. via {@code sourceDirs.from(file('a'), file('b'))}. @@ -107,4 +112,29 @@ public GherkinToAsciidocExtension() {} * @return mutable boolean property controlling grouping by feature */ public abstract Property getGroupByFeature(); + + /** + * Directory that the {@code listed.adoc}/{@code defined.adoc}/{@code implemented.adoc} report + * snippets are written to when {@link #getTrackProgress()} is {@code true}. Defaults to + * {@code build/generated-docs/features/snippets}. + * + *

When {@link #getGroupByFeature()} is {@code true}, snippets for a status with at least one + * scenario are written per feature, under {@code //.adoc}.

+ * + * @return mutable directory property for the snippet output directory + */ + public abstract DirectoryProperty getSnippetDir(); + + /** + * Optional Mustache template used to render the generated AsciiDoc file so that it references + * the report snippets via {@code include::} directives, instead of embedding their content + * verbatim. Only consulted when {@link #getTrackProgress()} is {@code true}; ignored otherwise. + * + *

When not set, the generated report looks exactly as it does without this property: + * scenario titles are embedded directly, with no {@code include::} directives. The snippet + * files are still written either way.

+ * + * @return mutable file property for the Mustache template file + */ + public abstract RegularFileProperty getTemplate(); } 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 c572ffa..99bec91 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 @@ -47,6 +47,8 @@ public void apply(Project project) { ext.getOutputDir().convention( project.getLayout().getBuildDirectory().dir("generated-docs")); ext.getOutputFileName().convention(GherkinToAsciidocExtension.DEFAULT_OUTPUT_FILE_NAME); + ext.getSnippetDir().convention( + project.getLayout().getBuildDirectory().dir(GherkinToAsciidocExtension.DEFAULT_SNIPPET_DIR)); project.getTasks().register(TASK_NAME, GenerateFeatureDocsTask.class, task -> { task.getSourceDirs().from(ext.getSourceDirs()); @@ -57,6 +59,8 @@ public void apply(Project project) { task.getTrackProgress().set(ext.getTrackProgress()); task.getGlueCodeDirs().from(ext.getGlueCodeDirs()); task.getGroupByFeature().set(ext.getGroupByFeature()); + task.getSnippetDir().set(ext.getSnippetDir()); + task.getTemplate().set(ext.getTemplate()); task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); }); } diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportOptions.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportOptions.java new file mode 100644 index 0000000..47e7b9d --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportOptions.java @@ -0,0 +1,17 @@ +package com.arc_e_tect.gradle.gherkin.progress; + +import java.io.File; + +/** + * Configuration for {@link ProgressReportWriter}. + * + * @param groupByFeature whether to group scenarios by their enclosing {@code Feature}, within + * each status, instead of a flat list + * @param snippetDir directory to write the {@code listed.adoc}/{@code defined.adoc}/ + * {@code implemented.adoc} snippet files to + * @param template a Mustache template file used to render the report so that it includes + * the generated snippets rather than embedding their content verbatim; + * {@code null} to use the built-in default report layout + */ +public record ProgressReportOptions(boolean groupByFeature, File snippetDir, File template) { +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriter.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriter.java index 2aaffd4..1352b40 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriter.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriter.java @@ -2,6 +2,8 @@ import com.arc_e_tect.gradle.gherkin.parser.ScenarioGrouping; import com.arc_e_tect.gradle.gherkin.parser.ScenarioInfo; +import com.arc_e_tect.gradle.gherkin.snippet.SnippetWriter; +import com.arc_e_tect.gradle.gherkin.snippet.StatusSnippets; import io.cucumber.cucumberexpressions.Expression; import org.gradle.api.GradleException; @@ -18,140 +20,180 @@ /** * Writes an AsciiDoc progress report that breaks scenarios down by - * {@link ScenarioStatus} and summarises the breakdown as a table of counts - * and percentages. Within each status, scenarios are grouped by their - * enclosing {@code Feature}. + * {@link ScenarioStatus} and summarises the breakdown as a table of counts and percentages. + * + *

Every call also writes the {@code listed.adoc}/{@code defined.adoc}/{@code implemented.adoc} + * snippet files (see {@link SnippetWriter}), regardless of whether a template is configured. When + * {@link ProgressReportOptions#template()} is set, the report itself is rendered from that Mustache + * template via {@link ReportTemplateRenderer} - referencing the snippets via {@code include::} + * directives - instead of embedding scenario titles verbatim.

*/ public class ProgressReportWriter { - private static final String LISTED_BLURB = - "Scenarios with a title only. No `Given`/`When`/`Then` steps have been written for them yet."; - private static final String DEFINED_BLURB = - "Scenarios with steps written, but at least one step has no matching glue code yet."; - private static final String IMPLEMENTED_BLURB = - "Scenarios whose every step has matching glue code."; - private final ScenarioClassifier classifier = new ScenarioClassifier(); + private final SnippetWriter snippetWriter = new SnippetWriter(); + private final ReportTemplateRenderer templateRenderer = new ReportTemplateRenderer(); /** Creates a new {@code ProgressReportWriter}. */ public ProgressReportWriter() {} /** - * Classifies every scenario and writes the progress report to {@code outputFile}. + * Classifies every scenario, writes the snippet files, and writes the progress report to + * {@code outputFile}. * * @param outputFile the AsciiDoc file to write * @param scenarios the scenarios to report on * @param glueCode step definition patterns found in the configured glue code directories + * @param options grouping, snippet, and template configuration */ - public void write(File outputFile, List scenarios, List glueCode) { - Map> scenariosByStatus = new EnumMap<>(ScenarioStatus.class); + public void write(File outputFile, List scenarios, List glueCode, + ProgressReportOptions options) { + if (scenarios.isEmpty()) { + writeEmptyReport(outputFile); + return; + } + + List summaries = buildSummaries(classify(scenarios, glueCode), scenarios.size()); + + Map snippets = new EnumMap<>(ScenarioStatus.class); + for (StatusSummary summary : summaries) { + snippets.put(summary.status(), snippetWriter.writeStatus( + options.snippetDir(), summary.status(), summary.scenarios(), options.groupByFeature())); + } + + if (options.template() != null) { + templateRenderer.render(outputFile, options.template(), summaries, snippets); + } else { + writeDefaultReport(outputFile, summaries, options.groupByFeature()); + } + } + + private Map> classify(List scenarios, List glueCode) { + Map> byStatus = new EnumMap<>(ScenarioStatus.class); for (ScenarioStatus status : ScenarioStatus.values()) { - scenariosByStatus.put(status, new ArrayList<>()); + byStatus.put(status, new ArrayList<>()); } for (ScenarioInfo scenario : scenarios) { - ScenarioStatus status = classifier.classify(scenario, glueCode); - scenariosByStatus.get(status).add(scenario); + byStatus.get(classifier.classify(scenario, glueCode)).add(scenario); } + return byStatus; + } + + private List buildSummaries(Map> byStatus, int total) { + List listed = byStatus.get(ScenarioStatus.LISTED); + List defined = byStatus.get(ScenarioStatus.DEFINED); + List implemented = byStatus.get(ScenarioStatus.IMPLEMENTED); + + BigDecimal listedPct = percentage(listed.size(), total); + BigDecimal definedPct = percentage(defined.size(), total); + // The implemented percentage is derived from the other two so that the three + // percentages always add up to exactly 100%, even after rounding. + BigDecimal implementedPct = BigDecimal.valueOf(100) + .subtract(listedPct) + .subtract(definedPct) + .setScale(1, RoundingMode.HALF_UP); + + return List.of( + new StatusSummary(ScenarioStatus.LISTED, "Listed", ReportText.LISTED_BLURB, + listed, listed.size(), listedPct.toPlainString()), + new StatusSummary(ScenarioStatus.DEFINED, "Defined", ReportText.DEFINED_BLURB, + defined, defined.size(), definedPct.toPlainString()), + new StatusSummary(ScenarioStatus.IMPLEMENTED, "Implemented", ReportText.IMPLEMENTED_BLURB, + implemented, implemented.size(), implementedPct.toPlainString())); + } + + private BigDecimal percentage(int count, int total) { + return BigDecimal.valueOf(count) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(total), 1, RoundingMode.HALF_UP); + } + private void writeEmptyReport(File outputFile) { try (PrintWriter writer = new PrintWriter(outputFile, StandardCharsets.UTF_8)) { writer.println("= Feature Scenarios"); writer.println(); - writer.println("This document lists every `Scenario` and `Scenario Outline` found under the " - + "configured feature file directories, classified by how far each one is toward being " - + "automated."); + writer.println(ReportText.INTRO); writer.println(); + writer.println("No scenarios found."); + } catch (IOException e) { + throw new GradleException("gherkinToAsciidoc: failed to write AsciiDoc file: " + outputFile, e); + } + } - if (scenarios.isEmpty()) { - writer.println("No scenarios found."); - return; - } + private void writeDefaultReport(File outputFile, List summaries, boolean groupByFeature) { + try (PrintWriter writer = new PrintWriter(outputFile, StandardCharsets.UTF_8)) { + writer.println("= Feature Scenarios"); + writer.println(); + writer.println(ReportText.INTRO); + writer.println(); - writeStatusLegend(writer); + writeStatusLegend(writer, summaries); writer.println("== Progress Summary"); writer.println(); - writeSummaryTable(writer, scenariosByStatus, scenarios.size()); + writeSummaryTable(writer, summaries); writer.println(); - writeSection(writer, "Listed", LISTED_BLURB, scenariosByStatus.get(ScenarioStatus.LISTED)); - writeSection(writer, "Defined", DEFINED_BLURB, scenariosByStatus.get(ScenarioStatus.DEFINED)); - writeSection(writer, "Implemented", IMPLEMENTED_BLURB, scenariosByStatus.get(ScenarioStatus.IMPLEMENTED)); + for (StatusSummary summary : summaries) { + writeSection(writer, summary, groupByFeature); + } } catch (IOException e) { throw new GradleException("gherkinToAsciidoc: failed to write AsciiDoc file: " + outputFile, e); } } - private void writeStatusLegend(PrintWriter writer) { + private void writeStatusLegend(PrintWriter writer, List summaries) { writer.println("Every scenario is classified as exactly one of:"); writer.println(); writer.println("[cols=\"1,3\",options=\"header\"]"); writer.println("|==="); writer.println("| Status | Meaning"); writer.println(); - writer.println("| Listed"); - writer.println("| " + LISTED_BLURB); - writer.println(); - writer.println("| Defined"); - writer.println("| " + DEFINED_BLURB); - writer.println(); - writer.println("| Implemented"); - writer.println("| " + IMPLEMENTED_BLURB); + for (StatusSummary summary : summaries) { + writer.println("| " + summary.label()); + writer.println("| " + summary.blurb()); + writer.println(); + } writer.println("|==="); writer.println(); } - private void writeSummaryTable( - PrintWriter writer, Map> scenariosByStatus, int total) { - int listed = scenariosByStatus.get(ScenarioStatus.LISTED).size(); - int defined = scenariosByStatus.get(ScenarioStatus.DEFINED).size(); - int implemented = scenariosByStatus.get(ScenarioStatus.IMPLEMENTED).size(); - - BigDecimal listedPct = percentage(listed, total); - BigDecimal definedPct = percentage(defined, total); - // The implemented percentage is derived from the other two so that the three - // percentages always add up to exactly 100%, even after rounding. - BigDecimal implementedPct = BigDecimal.valueOf(100) - .subtract(listedPct) - .subtract(definedPct) - .setScale(1, RoundingMode.HALF_UP); - + private void writeSummaryTable(PrintWriter writer, List summaries) { writer.println("[cols=\"1,1,1\",options=\"header\"]"); writer.println("|==="); writer.println("| Status | Count | Percentage"); writer.println(); - writeRow(writer, "Listed", listed, listedPct); - writeRow(writer, "Defined", defined, definedPct); - writeRow(writer, "Implemented", implemented, implementedPct); + for (StatusSummary summary : summaries) { + writer.println("| " + summary.label()); + writer.println("| " + summary.count()); + writer.println("| " + summary.percentage() + "%"); + writer.println(); + } writer.println("|==="); } - private void writeRow(PrintWriter writer, String label, int count, BigDecimal percentage) { - writer.println("| " + label); - writer.println("| " + count); - writer.println("| " + percentage.toPlainString() + "%"); + private void writeSection(PrintWriter writer, StatusSummary summary, boolean groupByFeature) { + writer.println("== " + summary.label()); writer.println(); - } - - private BigDecimal percentage(int count, int total) { - return BigDecimal.valueOf(count) - .multiply(BigDecimal.valueOf(100)) - .divide(BigDecimal.valueOf(total), 1, RoundingMode.HALF_UP); - } - - private void writeSection(PrintWriter writer, String heading, String blurb, List scenarios) { - writer.println("== " + heading); - writer.println(); - writer.println(blurb); + writer.println(summary.blurb()); writer.println(); + List scenarios = summary.scenarios(); if (scenarios.isEmpty()) { writer.println("_None._"); writer.println(); return; } - for (Map.Entry> entry : ScenarioGrouping.byFeatureTitle(scenarios).entrySet()) { - writer.println("=== " + entry.getKey()); - writer.println(); - for (ScenarioInfo scenario : entry.getValue()) { + if (groupByFeature) { + for (Map.Entry> entry : ScenarioGrouping.byFeatureTitle(scenarios).entrySet()) { + writer.println("=== " + entry.getKey()); + writer.println(); + for (ScenarioInfo scenario : entry.getValue()) { + writer.println("* " + scenario.title()); + } + writer.println(); + } + } else { + for (ScenarioInfo scenario : scenarios) { writer.println("* " + scenario.title()); } writer.println(); diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRenderer.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRenderer.java new file mode 100644 index 0000000..e085ff3 --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRenderer.java @@ -0,0 +1,102 @@ +package com.arc_e_tect.gradle.gherkin.progress; + +import com.arc_e_tect.gradle.gherkin.snippet.FeatureSnippet; +import com.arc_e_tect.gradle.gherkin.snippet.StatusSnippets; +import com.github.mustachejava.DefaultMustacheFactory; +import com.github.mustachejava.Mustache; +import com.github.mustachejava.MustacheException; +import org.gradle.api.GradleException; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Renders a progress report from a user-supplied Mustache template, so that the generated + * AsciiDoc file references the snippets written by {@link com.arc_e_tect.gradle.gherkin.snippet.SnippetWriter} + * via {@code include::} directives instead of embedding their content verbatim. + */ +public class ReportTemplateRenderer { + + /** Creates a new {@code ReportTemplateRenderer}. */ + public ReportTemplateRenderer() {} + + /** + * Renders {@code template} to {@code outputFile}, using {@code summaries} and {@code snippets} + * to build the Mustache context. Snippet paths in the context are relative to + * {@code outputFile}'s directory, so plain {@code include::[]} directives resolve correctly. + * + * @param outputFile the AsciiDoc file to write + * @param template the Mustache template file to render + * @param summaries the classified scenarios and summary figures for each status, in display order + * @param snippets the snippet file(s) written for each status + */ + public void render( + File outputFile, + File template, + List summaries, + Map snippets) { + Map context = buildContext(summaries, snippets, outputFile.getParentFile()); + + try (Reader templateReader = new FileReader(template, StandardCharsets.UTF_8); + PrintWriter writer = new PrintWriter(outputFile, StandardCharsets.UTF_8)) { + Mustache mustache = new DefaultMustacheFactory().compile(templateReader, template.getName()); + mustache.execute(writer, context).flush(); + } catch (IOException e) { + throw new GradleException("gherkinToAsciidoc: failed to render template '" + template + "': " + + e.getMessage(), e); + } catch (MustacheException e) { + throw new GradleException("gherkinToAsciidoc: failed to render template '" + template + "': " + + e.getMessage(), e); + } + } + + private Map buildContext( + List summaries, Map snippets, File outputDir) { + Map context = new LinkedHashMap<>(); + context.put("intro", ReportText.INTRO); + context.put("legend", summaries.stream() + .map(s -> Map.of("status", s.label(), "meaning", s.blurb())) + .collect(Collectors.toList())); + context.put("summary", summaries.stream() + .map(s -> Map.of("status", s.label(), "count", s.count(), "percentage", s.percentage())) + .collect(Collectors.toList())); + context.put("sections", summaries.stream() + .map(s -> sectionContext(s, snippets.get(s.status()), outputDir)) + .collect(Collectors.toList())); + return context; + } + + private Map sectionContext(StatusSummary summary, StatusSnippets snippets, File outputDir) { + Map section = new LinkedHashMap<>(); + section.put("status", summary.label()); + section.put("blurb", summary.blurb()); + if (snippets.features().isEmpty()) { + section.put("features", List.of()); + section.put("snippet", relativePath(outputDir, snippets.flatFile())); + } else { + section.put("snippet", ""); + section.put("features", snippets.features().stream() + .map(f -> featureContext(f, outputDir)) + .collect(Collectors.toList())); + } + return section; + } + + private Map featureContext(FeatureSnippet feature, File outputDir) { + return Map.of( + "title", feature.featureTitle(), + "snippet", relativePath(outputDir, feature.file())); + } + + private String relativePath(File baseDir, File target) { + return baseDir.toPath().relativize(target.toPath()).toString().replace(File.separatorChar, '/'); + } +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportText.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportText.java new file mode 100644 index 0000000..fe6fc5d --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/ReportText.java @@ -0,0 +1,24 @@ +package com.arc_e_tect.gradle.gherkin.progress; + +/** Shared explanatory text used by both {@link ProgressReportWriter} and {@link ReportTemplateRenderer}. */ +public final class ReportText { + + private ReportText() {} + + /** One-sentence description of what the generated document contains. */ + public static final String INTRO = + "This document lists every `Scenario` and `Scenario Outline` found under the configured feature " + + "file directories, classified by how far each one is toward being automated."; + + /** Explanation of the {@code listed} status. */ + public static final String LISTED_BLURB = + "Scenarios with a title only. No `Given`/`When`/`Then` steps have been written for them yet."; + + /** Explanation of the {@code defined} status. */ + public static final String DEFINED_BLURB = + "Scenarios with steps written, but at least one step has no matching glue code yet."; + + /** Explanation of the {@code implemented} status. */ + public static final String IMPLEMENTED_BLURB = + "Scenarios whose every step has matching glue code."; +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/StatusSummary.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/StatusSummary.java new file mode 100644 index 0000000..ef491d8 --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/progress/StatusSummary.java @@ -0,0 +1,21 @@ +package com.arc_e_tect.gradle.gherkin.progress; + +import com.arc_e_tect.gradle.gherkin.parser.ScenarioInfo; + +import java.util.List; + +/** + * The scenarios classified under one {@link ScenarioStatus}, along with the display text and + * summary figures used to render that status's heading, blurb, and summary table row. + * + * @param status the status these scenarios were classified as + * @param label the display label, e.g. {@code "Listed"} + * @param blurb a one-sentence explanation of what {@code status} means + * @param scenarios the scenarios classified as {@code status} + * @param count {@code scenarios.size()} + * @param percentage {@code count} as a percentage of the total scenario count, formatted to one + * decimal place, e.g. {@code "33.3"} + */ +public record StatusSummary( + ScenarioStatus status, String label, String blurb, List scenarios, int count, String percentage) { +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatter.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatter.java new file mode 100644 index 0000000..c969b8b --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatter.java @@ -0,0 +1,30 @@ +package com.arc_e_tect.gradle.gherkin.snippet; + +/** Converts a {@code Feature} title into a directory-safe camelCase name. */ +public final class FeatureNameFormatter { + + private FeatureNameFormatter() {} + + /** + * Converts {@code featureTitle} into camelCase with no spaces, e.g. + * {@code "User authentication"} becomes {@code "userAuthentication"}. + * + * @param featureTitle the Feature's title, as parsed from the {@code .feature} file + * @return the camelCase directory name; empty if {@code featureTitle} is blank + */ + public static String toDirectoryName(String featureTitle) { + String[] words = featureTitle.trim().split("\\s+"); + StringBuilder name = new StringBuilder(); + for (String word : words) { + if (word.isEmpty()) { + continue; + } + if (name.isEmpty()) { + name.append(Character.toLowerCase(word.charAt(0))).append(word.substring(1)); + } else { + name.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + } + return name.toString(); + } +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureSnippet.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureSnippet.java new file mode 100644 index 0000000..eb25a26 --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureSnippet.java @@ -0,0 +1,12 @@ +package com.arc_e_tect.gradle.gherkin.snippet; + +import java.io.File; + +/** + * A single snippet file written for one {@code Feature}, within one status. + * + * @param featureTitle the Feature's title, e.g. {@code "User authentication"} + * @param file the snippet file written for this feature + */ +public record FeatureSnippet(String featureTitle, File file) { +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriter.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriter.java new file mode 100644 index 0000000..5acb726 --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriter.java @@ -0,0 +1,77 @@ +package com.arc_e_tect.gradle.gherkin.snippet; + +import com.arc_e_tect.gradle.gherkin.parser.ScenarioGrouping; +import com.arc_e_tect.gradle.gherkin.parser.ScenarioInfo; +import com.arc_e_tect.gradle.gherkin.progress.ScenarioStatus; +import org.gradle.api.GradleException; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Writes the {@code listed.adoc}/{@code defined.adoc}/{@code implemented.adoc} snippet files that + * back a progress report's {@code include::} directives. + */ +public class SnippetWriter { + + /** Creates a new {@code SnippetWriter}. */ + public SnippetWriter() {} + + /** + * Writes the snippet file(s) for a single status. + * + *

When {@code groupByFeature} is {@code true} and {@code scenarios} is non-empty, one file is + * written per feature, under {@code //.adoc}. Otherwise + * a single flat file is written at {@code /.adoc}, containing a bullet per + * scenario, or {@code _None._} when {@code scenarios} is empty.

+ * + * @param snippetDir the configured snippet output directory + * @param status the status these scenarios were classified as + * @param scenarios the scenarios classified as {@code status} + * @param groupByFeature whether to split the snippet by feature + * @return a description of the snippet file(s) written + */ + public StatusSnippets writeStatus( + File snippetDir, ScenarioStatus status, List scenarios, boolean groupByFeature) { + String fileName = status.name().toLowerCase(Locale.ROOT) + ".adoc"; + + if (groupByFeature && !scenarios.isEmpty()) { + List features = new ArrayList<>(); + for (Map.Entry> entry : ScenarioGrouping.byFeatureTitle(scenarios).entrySet()) { + String directoryName = FeatureNameFormatter.toDirectoryName(entry.getKey()); + File file = new File(new File(snippetDir, directoryName), fileName); + writeLines(file, entry.getValue()); + features.add(new FeatureSnippet(entry.getKey(), file)); + } + return new StatusSnippets(features, null); + } + + File file = new File(snippetDir, fileName); + writeLines(file, scenarios); + return new StatusSnippets(List.of(), file); + } + + private void writeLines(File file, List scenarios) { + File parent = file.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new GradleException("gherkinToAsciidoc: could not create snippet directory: " + parent); + } + try (PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) { + if (scenarios.isEmpty()) { + writer.println("_None._"); + } else { + for (ScenarioInfo scenario : scenarios) { + writer.println("* " + scenario.title()); + } + } + } catch (IOException e) { + throw new GradleException("gherkinToAsciidoc: failed to write snippet file: " + file, e); + } + } +} diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/StatusSnippets.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/StatusSnippets.java new file mode 100644 index 0000000..110eac0 --- /dev/null +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/snippet/StatusSnippets.java @@ -0,0 +1,17 @@ +package com.arc_e_tect.gradle.gherkin.snippet; + +import java.io.File; +import java.util.List; + +/** + * The snippet file(s) written for one {@link com.arc_e_tect.gradle.gherkin.progress.ScenarioStatus}. + * + *

Exactly one of {@link #features()} or {@link #flatFile()} is populated: {@code features} when + * the status has at least one scenario and scenarios are grouped by feature; {@code flatFile} + * otherwise (grouping disabled, or the status has no scenarios at all).

+ * + * @param features the per-feature snippet files, in feature order; empty when {@link #flatFile()} is used + * @param flatFile the single flat snippet file for this status; {@code null} when {@link #features()} is used + */ +public record StatusSnippets(List features, File flatFile) { +} 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 4d80ceb..621c287 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 @@ -88,6 +88,25 @@ void groupByFeatureDefaultsToTrueWhenTrackProgressEnabled() { assertThat(ext.getGroupByFeature().get()).isTrue(); } + @Test + @DisplayName("extension default: snippetDir is build/generated-docs/features/snippets") + void extensionDefaultSnippetDirIsGeneratedDocsFeaturesSnippets() { + Project project = projectWithPlugin(); + GherkinToAsciidocExtension ext = extension(project); + + assertThat(ext.getSnippetDir().get().getAsFile().getPath()) + .endsWith(String.join(File.separator, "build", "generated-docs", "features", "snippets")); + } + + @Test + @DisplayName("extension default: template is not set") + void extensionDefaultTemplateIsNotSet() { + Project project = projectWithPlugin(); + GherkinToAsciidocExtension ext = extension(project); + + assertThat(ext.getTemplate().isPresent()).isFalse(); + } + @Test @DisplayName("extension default: outputFileName is features.adoc") void extensionDefaultOutputFileNameIsFeaturesAdoc() { @@ -436,6 +455,92 @@ public void outstandingInvoice() {} + System.lineSeparator() + System.lineSeparator() + "_None._"); } + @Test + @DisplayName("writes listed/defined/implemented snippet files when trackProgress is enabled") + void writesSnippetFilesWhenTrackProgressEnabled() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", """ + Feature: Sample + + Scenario: Only a title + + Scenario: Fully wired up + Given an implemented step + """); + + File glueCodeDir = new File(tempDir.toFile(), "steps"); + glueCodeDir.mkdirs(); + Files.writeString(new File(glueCodeDir, "Steps.java").toPath(), """ + public class Steps { + @Given("an implemented step") + public void implemented() {} + } + """); + + File outputDir = new File(tempDir.toFile(), "output"); + File snippetDir = new File(tempDir.toFile(), "snippets"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getTrackProgress().set(true); + task.getGlueCodeDirs().from(glueCodeDir); + task.getGroupByFeature().set(true); + task.getSnippetDir().set(snippetDir); + task.getOutputDir().set(outputDir); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + File featureDir = new File(snippetDir, "sample"); + File listedFile = new File(featureDir, "listed.adoc"); + File implementedFile = new File(featureDir, "implemented.adoc"); + assertThat(listedFile).exists(); + assertThat(implementedFile).exists(); + assertThat(Files.readString(listedFile.toPath())).contains("* Scenario: Only a title"); + assertThat(Files.readString(implementedFile.toPath())).contains("* Scenario: Fully wired up"); + } + + @Test + @DisplayName("renders the report from a template referencing the generated snippets") + void generatesReportFromTemplateEndToEnd() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", + "Feature: Sample\n\n Scenario: Fully wired up\n Given an implemented step\n"); + + File glueCodeDir = new File(tempDir.toFile(), "steps"); + glueCodeDir.mkdirs(); + Files.writeString(new File(glueCodeDir, "Steps.java").toPath(), """ + public class Steps { + @Given("an implemented step") + public void implemented() {} + } + """); + + File templateFile = new File(tempDir.toFile(), "report.mustache"); + Files.writeString(templateFile.toPath(), + "= Custom Report\n{{#sections}}{{{status}}}\n{{/sections}}"); + + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getTrackProgress().set(true); + task.getGlueCodeDirs().from(glueCodeDir); + task.getGroupByFeature().set(true); + task.getSnippetDir().set(new File(tempDir.toFile(), "snippets")); + task.getTemplate().set(templateFile); + task.getOutputDir().set(outputDir); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + String content = Files.readString(new File(outputDir, "features.adoc").toPath()); + assertThat(content).startsWith("= Custom Report"); + assertThat(content).doesNotContain("Progress Summary"); + } + @Test @DisplayName("output file starts with = Feature Scenarios header") void outputFileContainsHeader() throws IOException { diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/DefaultEquivalentTemplatesTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/DefaultEquivalentTemplatesTest.java new file mode 100644 index 0000000..874d2a2 --- /dev/null +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/DefaultEquivalentTemplatesTest.java @@ -0,0 +1,127 @@ +package com.arc_e_tect.gradle.gherkin.progress; + +import com.arc_e_tect.gradle.gherkin.parser.ScenarioInfo; +import io.cucumber.cucumberexpressions.Expression; +import io.cucumber.cucumberexpressions.ExpressionFactory; +import io.cucumber.cucumberexpressions.ParameterTypeRegistry; +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.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the two reference Mustache templates documented in the plugin README + * ({@code templates/grouped.mustache} and {@code templates/flat.mustache}) reproduce, via + * {@code include::} directives, exactly the same headings, blurbs, summary table, and per-status + * scenario listings as the built-in default report (no template configured). + */ +@DisplayName("Default-equivalent reference templates") +class DefaultEquivalentTemplatesTest { + + private static final Pattern INCLUDE = Pattern.compile("include::(.+)\\[]"); + + private final ProgressReportWriter writer = new ProgressReportWriter(); + private final ExpressionFactory expressionFactory = + new ExpressionFactory(new ParameterTypeRegistry(Locale.ENGLISH)); + + @Test + @DisplayName("grouped.mustache reproduces the default grouped report") + void groupedTemplateReproducesDefaultReport(@TempDir Path tempDir) throws IOException, URISyntaxException { + List scenarios = List.of( + new ScenarioInfo("Authentication", "Scenario: Only a title", List.of()), + new ScenarioInfo("Authentication", "Scenario: Has steps, no glue", List.of("an unimplemented step")), + new ScenarioInfo("Authentication", "Scenario: Fully wired up", List.of("an implemented step")), + new ScenarioInfo("Billing", "Scenario: Pays an invoice", List.of("an implemented step"))); + List glueCode = List.of(expression("an implemented step")); + + assertTemplateReproducesDefault(tempDir, scenarios, glueCode, true, "templates/grouped.mustache"); + } + + @Test + @DisplayName("flat.mustache reproduces the default flat (non-grouped) report") + void flatTemplateReproducesDefaultReport(@TempDir Path tempDir) throws IOException, URISyntaxException { + List scenarios = List.of( + new ScenarioInfo("Authentication", "Scenario: Only a title", List.of()), + new ScenarioInfo("Authentication", "Scenario: Has steps, no glue", List.of("an unimplemented step")), + new ScenarioInfo("Billing", "Scenario: Pays an invoice", List.of("an implemented step"))); + List glueCode = List.of(expression("an implemented step")); + + assertTemplateReproducesDefault(tempDir, scenarios, glueCode, false, "templates/flat.mustache"); + } + + private void assertTemplateReproducesDefault( + Path tempDir, List scenarios, List glueCode, boolean groupByFeature, + String templateResource) throws IOException, URISyntaxException { + File defaultOutput = tempDir.resolve("default.adoc").toFile(); + File defaultSnippets = tempDir.resolve("default-snippets").toFile(); + writer.write(defaultOutput, scenarios, glueCode, + new ProgressReportOptions(groupByFeature, defaultSnippets, null)); + String defaultContent = Files.readString(defaultOutput.toPath(), StandardCharsets.UTF_8); + + File templatedOutput = tempDir.resolve("templated.adoc").toFile(); + File templatedSnippets = tempDir.resolve("templated-snippets").toFile(); + File template = fixtureFile(templateResource); + writer.write(templatedOutput, scenarios, glueCode, + new ProgressReportOptions(groupByFeature, templatedSnippets, template)); + String templatedContent = Files.readString(templatedOutput.toPath(), StandardCharsets.UTF_8); + + // The legend and summary table are embedded verbatim by both, so they must be byte-identical. + assertThat(extractBetween(templatedContent, "Every scenario is classified", "== Progress Summary")) + .isEqualTo(extractBetween(defaultContent, "Every scenario is classified", "== Progress Summary")); + assertThat(extractBetween(templatedContent, "== Progress Summary", "== Listed")) + .isEqualTo(extractBetween(defaultContent, "== Progress Summary", "== Listed")); + + // Resolving every include:: directive (as an AsciiDoc processor would) must reproduce + // exactly the same document as the default (no-template) report. + String resolved = resolveIncludes(templatedContent, templatedOutput.getParentFile()); + assertThat(resolved).isEqualTo(defaultContent); + + assertThat(defaultContent).contains("* Scenario:"); + assertThat(templatedContent).doesNotContain("* Scenario:").contains("include::"); + } + + private String resolveIncludes(String content, File baseDir) throws IOException { + StringBuilder result = new StringBuilder(); + for (String line : content.lines().toList()) { + Matcher matcher = INCLUDE.matcher(line.trim()); + if (matcher.matches()) { + File snippetFile = new File(baseDir, matcher.group(1)); + result.append(Files.readString(snippetFile.toPath(), StandardCharsets.UTF_8)); + } else { + result.append(line).append("\n"); + } + } + return result.toString(); + } + + private String extractBetween(String content, String start, String end) { + int startIndex = content.indexOf(start); + int endIndex = content.indexOf(end, startIndex); + return content.substring(startIndex, endIndex); + } + + private Expression expression(String pattern) { + return expressionFactory.createExpression(pattern); + } + + private File fixtureFile(String resourcePath) throws URISyntaxException { + URL resource = getClass().getClassLoader().getResource(resourcePath); + if (resource == null) { + throw new IllegalArgumentException("Fixture not found on classpath: " + resourcePath); + } + return new File(resource.toURI()); + } +} diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriterTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriterTest.java index b5f35aa..1d1358f 100644 --- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriterTest.java +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ProgressReportWriterTest.java @@ -32,7 +32,7 @@ void includesIntroAndStatusLegend(@TempDir Path tempDir) throws IOException { ScenarioInfo listed = new ScenarioInfo("Authentication", "Scenario: Only a title", List.of()); File outputFile = tempDir.resolve("features.adoc").toFile(); - writer.write(outputFile, List.of(listed), List.of()); + writer.write(outputFile, List.of(listed), List.of(), grouped(tempDir)); String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); assertThat(content) @@ -55,7 +55,7 @@ void reportsScenariosUnderEachHeading(@TempDir Path tempDir) throws IOException List glueCode = List.of(expression("an implemented step")); File outputFile = tempDir.resolve("features.adoc").toFile(); - writer.write(outputFile, List.of(listed, defined, implemented), glueCode); + writer.write(outputFile, List.of(listed, defined, implemented), glueCode, grouped(tempDir)); String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); assertThat(content).contains("== Listed", "* Scenario: Only a title"); @@ -64,7 +64,7 @@ void reportsScenariosUnderEachHeading(@TempDir Path tempDir) throws IOException } @Test - @DisplayName("groups scenarios under their feature within each status section") + @DisplayName("groups scenarios under their feature within each status section when groupByFeature is true") void groupsScenariosByFeatureWithinEachStatus(@TempDir Path tempDir) throws IOException { ScenarioInfo authImplemented = new ScenarioInfo( "Authentication", "Scenario: User logs in", List.of("has glue")); @@ -73,7 +73,7 @@ void groupsScenariosByFeatureWithinEachStatus(@TempDir Path tempDir) throws IOEx List glueCode = List.of(expression("has glue")); File outputFile = tempDir.resolve("features.adoc").toFile(); - writer.write(outputFile, List.of(authImplemented, billingImplemented), glueCode); + writer.write(outputFile, List.of(authImplemented, billingImplemented), glueCode, grouped(tempDir)); String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); assertThat(content) @@ -81,8 +81,6 @@ void groupsScenariosByFeatureWithinEachStatus(@TempDir Path tempDir) throws IOEx .contains("=== Authentication") .contains("=== Billing"); - // The Authentication feature heading appears before its scenario, which appears - // before the Billing feature heading. int authHeadingIndex = content.indexOf("=== Authentication"); int authScenarioIndex = content.indexOf("* Scenario: User logs in"); int billingHeadingIndex = content.indexOf("=== Billing"); @@ -92,6 +90,25 @@ void groupsScenariosByFeatureWithinEachStatus(@TempDir Path tempDir) throws IOEx assertThat(billingHeadingIndex).isLessThan(billingScenarioIndex); } + @Test + @DisplayName("keeps a flat list per status, with no feature headings, when groupByFeature is false") + void keepsFlatListWhenGroupByFeatureIsFalse(@TempDir Path tempDir) throws IOException { + ScenarioInfo authImplemented = new ScenarioInfo( + "Authentication", "Scenario: User logs in", List.of("has glue")); + ScenarioInfo billingImplemented = new ScenarioInfo( + "Billing", "Scenario: User pays an invoice", List.of("has glue")); + List glueCode = List.of(expression("has glue")); + + File outputFile = tempDir.resolve("features.adoc").toFile(); + writer.write(outputFile, List.of(authImplemented, billingImplemented), glueCode, flat(tempDir)); + + String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); + assertThat(content) + .contains("== Implemented", "* Scenario: User logs in", "* Scenario: User pays an invoice") + .doesNotContain("=== Authentication") + .doesNotContain("=== Billing"); + } + @Test @DisplayName("groups multiple scenarios from the same feature under one feature heading") void groupsMultipleScenariosFromSameFeatureTogether(@TempDir Path tempDir) throws IOException { @@ -99,7 +116,7 @@ void groupsMultipleScenariosFromSameFeatureTogether(@TempDir Path tempDir) throw ScenarioInfo second = new ScenarioInfo("Authentication", "Scenario: Second", List.of()); File outputFile = tempDir.resolve("features.adoc").toFile(); - writer.write(outputFile, List.of(first, second), List.of()); + writer.write(outputFile, List.of(first, second), List.of(), grouped(tempDir)); String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); assertThat(content.split("=== Authentication", -1)).hasSize(2); @@ -119,7 +136,7 @@ void summaryTableCountsAndPercentagesAddUpTo100Percent(@TempDir Path tempDir) th List glueCode = List.of(expression("has glue")); File outputFile = tempDir.resolve("features.adoc").toFile(); - writer.write(outputFile, scenarios, glueCode); + writer.write(outputFile, scenarios, glueCode, grouped(tempDir)); String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); assertThat(content) @@ -132,7 +149,7 @@ void summaryTableCountsAndPercentagesAddUpTo100Percent(@TempDir Path tempDir) th @DisplayName("prints a placeholder message when there are no scenarios") void printsPlaceholderWhenNoScenarios(@TempDir Path tempDir) throws IOException { File outputFile = tempDir.resolve("features.adoc").toFile(); - writer.write(outputFile, List.of(), List.of()); + writer.write(outputFile, List.of(), List.of(), grouped(tempDir)); String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); assertThat(content).contains("No scenarios found."); @@ -147,25 +164,75 @@ void printsNonePlaceholderForEmptyHeading(@TempDir Path tempDir) throws IOExcept List glueCode = List.of(expression("an implemented step")); File outputFile = tempDir.resolve("features.adoc").toFile(); - writer.write(outputFile, List.of(implemented), glueCode); + writer.write(outputFile, List.of(implemented), glueCode, grouped(tempDir)); String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); assertThat(content).contains("== Listed", "_None._"); - // No feature sub-heading is printed for a status with no scenarios. String listedSection = content.substring(content.indexOf("== Listed"), content.indexOf("== Defined")); assertThat(listedSection).doesNotContain("==="); } + @Test + @DisplayName("always writes the listed/defined/implemented snippet files, even without a template") + void alwaysWritesSnippetFiles(@TempDir Path tempDir) throws IOException { + File snippetDir = tempDir.resolve("snippets").toFile(); + ScenarioInfo implemented = new ScenarioInfo( + "Authentication", "Scenario: Fully wired up", List.of("an implemented step")); + List glueCode = List.of(expression("an implemented step")); + + File outputFile = tempDir.resolve("features.adoc").toFile(); + writer.write(outputFile, List.of(implemented), glueCode, + new ProgressReportOptions(true, snippetDir, null)); + + File listedFile = new File(snippetDir, "listed.adoc"); + File definedFile = new File(snippetDir, "defined.adoc"); + File implementedFile = new File(new File(snippetDir, "authentication"), "implemented.adoc"); + assertThat(listedFile).exists(); + assertThat(definedFile).exists(); + assertThat(implementedFile).exists(); + assertThat(Files.readString(listedFile.toPath(), StandardCharsets.UTF_8).trim()).isEqualTo("_None._"); + assertThat(Files.readString(implementedFile.toPath(), StandardCharsets.UTF_8)) + .contains("* Scenario: Fully wired up"); + } + + @Test + @DisplayName("renders the report from a template instead of embedding scenario titles, when a template is set") + void rendersReportFromTemplateWhenConfigured(@TempDir Path tempDir) throws IOException { + File templateFile = tempDir.resolve("report.mustache").toFile(); + Files.writeString(templateFile.toPath(), + "TEMPLATE OUTPUT\n{{#sections}}{{{status}}}: {{{snippet}}}\n{{/sections}}"); + ScenarioInfo implemented = new ScenarioInfo( + "Authentication", "Scenario: Fully wired up", List.of("an implemented step")); + List glueCode = List.of(expression("an implemented step")); + + File outputFile = tempDir.resolve("features.adoc").toFile(); + writer.write(outputFile, List.of(implemented), glueCode, + new ProgressReportOptions(false, tempDir.resolve("snippets").toFile(), templateFile)); + + String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); + assertThat(content).contains("TEMPLATE OUTPUT"); + assertThat(content).doesNotContain("Progress Summary"); + assertThat(content).contains("snippets/listed.adoc"); + } + @Test @DisplayName("throws a GradleException when the output file cannot be written") void throwsWhenOutputFileCannotBeWritten(@TempDir Path tempDir) { File directoryAsFile = tempDir.toFile(); - assertThatThrownBy(() -> writer.write(directoryAsFile, List.of(), List.of())) + assertThatThrownBy(() -> writer.write(directoryAsFile, List.of(), List.of(), grouped(tempDir))) .isInstanceOf(org.gradle.api.GradleException.class); } private Expression expression(String pattern) { return expressionFactory.createExpression(pattern); } + + private ProgressReportOptions grouped(Path tempDir) { + return new ProgressReportOptions(true, tempDir.resolve("snippets").toFile(), null); + } + + private ProgressReportOptions flat(Path tempDir) { + return new ProgressReportOptions(false, tempDir.resolve("snippets").toFile(), null); + } } diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRendererTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRendererTest.java new file mode 100644 index 0000000..2a8ef1a --- /dev/null +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/progress/ReportTemplateRendererTest.java @@ -0,0 +1,170 @@ +package com.arc_e_tect.gradle.gherkin.progress; + +import com.arc_e_tect.gradle.gherkin.snippet.FeatureSnippet; +import com.arc_e_tect.gradle.gherkin.snippet.StatusSnippets; +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.EnumMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ReportTemplateRenderer") +class ReportTemplateRendererTest { + + private final ReportTemplateRenderer renderer = new ReportTemplateRenderer(); + + @Test + @DisplayName("renders a flat include per status when scenarios are not grouped by feature") + void rendersFlatIncludePerStatus(@TempDir Path tempDir) throws IOException { + File outputFile = tempDir.resolve("out/features.adoc").toFile(); + outputFile.getParentFile().mkdirs(); + File snippetDir = tempDir.resolve("out/snippets").toFile(); + File listedSnippet = new File(snippetDir, "listed.adoc"); + + Map snippets = new EnumMap<>(ScenarioStatus.class); + snippets.put(ScenarioStatus.LISTED, new StatusSnippets(List.of(), listedSnippet)); + snippets.put(ScenarioStatus.DEFINED, new StatusSnippets(List.of(), new File(snippetDir, "defined.adoc"))); + snippets.put(ScenarioStatus.IMPLEMENTED, + new StatusSnippets(List.of(), new File(snippetDir, "implemented.adoc"))); + + List summaries = List.of( + new StatusSummary(ScenarioStatus.LISTED, "Listed", "blurb-listed", List.of(), 1, "33.3"), + new StatusSummary(ScenarioStatus.DEFINED, "Defined", "blurb-defined", List.of(), 1, "33.3"), + new StatusSummary(ScenarioStatus.IMPLEMENTED, "Implemented", "blurb-implemented", List.of(), 1, "33.4")); + + File template = writeTemplate(tempDir, + "{{#sections}}== {{{status}}}\n{{{blurb}}}\n" + + "{{^features}}include::{{{snippet}}}[]\n{{/features}}" + + "{{#features}}=== {{{title}}}\ninclude::{{{snippet}}}[]\n{{/features}}\n{{/sections}}"); + + renderer.render(outputFile, template, summaries, snippets); + + String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); + assertThat(content) + .contains("== Listed", "blurb-listed", "include::snippets/listed.adoc[]") + .contains("== Defined", "blurb-defined", "include::snippets/defined.adoc[]") + .contains("== Implemented", "blurb-implemented", "include::snippets/implemented.adoc[]") + .doesNotContain("==="); + } + + @Test + @DisplayName("renders a nested per-feature include when scenarios are grouped by feature") + void rendersNestedIncludePerFeature(@TempDir Path tempDir) throws IOException { + File outputFile = tempDir.resolve("out/features.adoc").toFile(); + outputFile.getParentFile().mkdirs(); + File snippetDir = tempDir.resolve("out/snippets").toFile(); + File authSnippet = new File(new File(snippetDir, "authentication"), "implemented.adoc"); + File billingSnippet = new File(new File(snippetDir, "billing"), "implemented.adoc"); + + Map snippets = new EnumMap<>(ScenarioStatus.class); + snippets.put(ScenarioStatus.LISTED, new StatusSnippets(List.of(), new File(snippetDir, "listed.adoc"))); + snippets.put(ScenarioStatus.DEFINED, new StatusSnippets(List.of(), new File(snippetDir, "defined.adoc"))); + snippets.put(ScenarioStatus.IMPLEMENTED, new StatusSnippets( + List.of(new FeatureSnippet("Authentication", authSnippet), + new FeatureSnippet("Billing", billingSnippet)), + null)); + + List summaries = List.of( + new StatusSummary(ScenarioStatus.LISTED, "Listed", "blurb-listed", List.of(), 0, "0.0"), + new StatusSummary(ScenarioStatus.DEFINED, "Defined", "blurb-defined", List.of(), 0, "0.0"), + new StatusSummary(ScenarioStatus.IMPLEMENTED, "Implemented", "blurb-implemented", List.of(), 2, "100.0")); + + File template = writeTemplate(tempDir, + "{{#sections}}== {{{status}}}\n" + + "{{^features}}include::{{{snippet}}}[]\n{{/features}}" + + "{{#features}}=== {{{title}}}\ninclude::{{{snippet}}}[]\n{{/features}}\n{{/sections}}"); + + renderer.render(outputFile, template, summaries, snippets); + + String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); + assertThat(content) + .contains("== Implemented") + .contains("=== Authentication", "include::snippets/authentication/implemented.adoc[]") + .contains("=== Billing", "include::snippets/billing/implemented.adoc[]"); + } + + @Test + @DisplayName("uses summary counts and percentages in the context") + void usesSummaryCountsAndPercentagesInContext(@TempDir Path tempDir) throws IOException { + File outputFile = tempDir.resolve("features.adoc").toFile(); + Map snippets = emptySnippets(tempDir); + List summaries = List.of( + new StatusSummary(ScenarioStatus.LISTED, "Listed", "b", List.of(), 2, "40.0"), + new StatusSummary(ScenarioStatus.DEFINED, "Defined", "b", List.of(), 1, "20.0"), + new StatusSummary(ScenarioStatus.IMPLEMENTED, "Implemented", "b", List.of(), 2, "40.0")); + + File template = writeTemplate(tempDir, + "{{#summary}}{{{status}}}={{count}} ({{percentage}}%)\n{{/summary}}"); + + renderer.render(outputFile, template, summaries, snippets); + + String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); + assertThat(content) + .contains("Listed=2 (40.0%)") + .contains("Defined=1 (20.0%)") + .contains("Implemented=2 (40.0%)"); + } + + @Test + @DisplayName("does not HTML-escape triple-mustache substitutions") + void doesNotEscapeTripleMustacheSubstitutions(@TempDir Path tempDir) throws IOException { + File outputFile = tempDir.resolve("features.adoc").toFile(); + File snippetDir = tempDir.resolve("snippets").toFile(); + Map snippets = new EnumMap<>(ScenarioStatus.class); + for (ScenarioStatus status : ScenarioStatus.values()) { + snippets.put(status, new StatusSnippets(List.of(), + new File(snippetDir, status.name().toLowerCase(java.util.Locale.ROOT) + ".adoc"))); + } + List summaries = List.of( + new StatusSummary(ScenarioStatus.LISTED, "R&D ", "b", List.of(), 0, "0.0"), + new StatusSummary(ScenarioStatus.DEFINED, "Defined", "b", List.of(), 0, "0.0"), + new StatusSummary(ScenarioStatus.IMPLEMENTED, "Implemented", "b", List.of(), 0, "0.0")); + + File template = writeTemplate(tempDir, "{{#legend}}{{{status}}}\n{{/legend}}"); + + renderer.render(outputFile, template, summaries, snippets); + + String content = Files.readString(outputFile.toPath(), StandardCharsets.UTF_8); + assertThat(content).contains("R&D "); + } + + @Test + @DisplayName("throws a GradleException when the template file does not exist") + void throwsWhenTemplateFileDoesNotExist(@TempDir Path tempDir) { + File outputFile = tempDir.resolve("features.adoc").toFile(); + File missingTemplate = tempDir.resolve("missing.mustache").toFile(); + List summaries = List.of( + new StatusSummary(ScenarioStatus.LISTED, "Listed", "b", List.of(), 0, "0.0"), + new StatusSummary(ScenarioStatus.DEFINED, "Defined", "b", List.of(), 0, "0.0"), + new StatusSummary(ScenarioStatus.IMPLEMENTED, "Implemented", "b", List.of(), 0, "0.0")); + + assertThatThrownBy(() -> renderer.render(outputFile, missingTemplate, summaries, emptySnippets(tempDir))) + .isInstanceOf(org.gradle.api.GradleException.class); + } + + private Map emptySnippets(Path tempDir) { + File snippetDir = tempDir.resolve("snippets").toFile(); + Map snippets = new EnumMap<>(ScenarioStatus.class); + for (ScenarioStatus status : ScenarioStatus.values()) { + snippets.put(status, new StatusSnippets(List.of(), + new File(snippetDir, status.name().toLowerCase(java.util.Locale.ROOT) + ".adoc"))); + } + return snippets; + } + + private File writeTemplate(Path tempDir, String content) throws IOException { + File file = Files.createTempFile(tempDir, "template", ".mustache").toFile(); + Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); + return file; + } +} diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatterTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatterTest.java new file mode 100644 index 0000000..ff43b67 --- /dev/null +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/FeatureNameFormatterTest.java @@ -0,0 +1,40 @@ +package com.arc_e_tect.gradle.gherkin.snippet; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("FeatureNameFormatter") +class FeatureNameFormatterTest { + + @Test + @DisplayName("converts a multi-word title to camelCase") + void convertsMultiWordTitleToCamelCase() { + assertThat(FeatureNameFormatter.toDirectoryName("User authentication")).isEqualTo("userAuthentication"); + } + + @Test + @DisplayName("lower-cases a single-word title") + void lowerCasesSingleWordTitle() { + assertThat(FeatureNameFormatter.toDirectoryName("Billing")).isEqualTo("billing"); + } + + @Test + @DisplayName("collapses multiple consecutive spaces") + void collapsesMultipleConsecutiveSpaces() { + assertThat(FeatureNameFormatter.toDirectoryName("Invoice payment flow")).isEqualTo("invoicePaymentFlow"); + } + + @Test + @DisplayName("trims leading and trailing whitespace") + void trimsLeadingAndTrailingWhitespace() { + assertThat(FeatureNameFormatter.toDirectoryName(" User authentication ")).isEqualTo("userAuthentication"); + } + + @Test + @DisplayName("returns an empty string for a blank title") + void returnsEmptyStringForBlankTitle() { + assertThat(FeatureNameFormatter.toDirectoryName(" ")).isEmpty(); + } +} diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriterTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriterTest.java new file mode 100644 index 0000000..6134612 --- /dev/null +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/snippet/SnippetWriterTest.java @@ -0,0 +1,81 @@ +package com.arc_e_tect.gradle.gherkin.snippet; + +import com.arc_e_tect.gradle.gherkin.parser.ScenarioInfo; +import com.arc_e_tect.gradle.gherkin.progress.ScenarioStatus; +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("SnippetWriter") +class SnippetWriterTest { + + private final SnippetWriter writer = new SnippetWriter(); + + @Test + @DisplayName("writes one flat snippet file when groupByFeature is false") + void writesFlatSnippetFileWhenNotGrouped(@TempDir Path tempDir) throws IOException { + ScenarioInfo auth = new ScenarioInfo("Authentication", "Scenario: A", List.of()); + ScenarioInfo billing = new ScenarioInfo("Billing", "Scenario: B", List.of()); + + StatusSnippets result = writer.writeStatus( + tempDir.toFile(), ScenarioStatus.LISTED, List.of(auth, billing), false); + + assertThat(result.features()).isEmpty(); + assertThat(result.flatFile()).isEqualTo(new File(tempDir.toFile(), "listed.adoc")); + String content = Files.readString(result.flatFile().toPath(), StandardCharsets.UTF_8); + assertThat(content).contains("* Scenario: A", "* Scenario: B"); + } + + @Test + @DisplayName("writes a None flat snippet file for an empty status") + void writesNoneFlatSnippetFileForEmptyStatus(@TempDir Path tempDir) throws IOException { + StatusSnippets result = writer.writeStatus(tempDir.toFile(), ScenarioStatus.DEFINED, List.of(), true); + + assertThat(result.features()).isEmpty(); + assertThat(result.flatFile()).isEqualTo(new File(tempDir.toFile(), "defined.adoc")); + String content = Files.readString(result.flatFile().toPath(), StandardCharsets.UTF_8); + assertThat(content.trim()).isEqualTo("_None._"); + } + + @Test + @DisplayName("writes one snippet file per feature, in a camelCase directory, when grouped") + void writesOneSnippetFilePerFeatureWhenGrouped(@TempDir Path tempDir) throws IOException { + ScenarioInfo auth = new ScenarioInfo("User authentication", "Scenario: A", List.of()); + ScenarioInfo billing = new ScenarioInfo("Invoice payment", "Scenario: B", List.of()); + + StatusSnippets result = writer.writeStatus( + tempDir.toFile(), ScenarioStatus.IMPLEMENTED, List.of(auth, billing), true); + + assertThat(result.flatFile()).isNull(); + assertThat(result.features()).extracting(FeatureSnippet::featureTitle) + .containsExactly("User authentication", "Invoice payment"); + + File authFile = new File(new File(tempDir.toFile(), "userAuthentication"), "implemented.adoc"); + File billingFile = new File(new File(tempDir.toFile(), "invoicePayment"), "implemented.adoc"); + assertThat(result.features().get(0).file()).isEqualTo(authFile); + assertThat(result.features().get(1).file()).isEqualTo(billingFile); + assertThat(Files.readString(authFile.toPath(), StandardCharsets.UTF_8)).contains("* Scenario: A"); + assertThat(Files.readString(billingFile.toPath(), StandardCharsets.UTF_8)).contains("* Scenario: B"); + } + + @Test + @DisplayName("throws a GradleException when the snippet file cannot be written") + void throwsWhenSnippetFileCannotBeWritten(@TempDir Path tempDir) throws IOException { + // Pre-create "listed.adoc" as a directory, so writing the snippet file to that same path fails. + Files.createDirectory(tempDir.resolve("listed.adoc")); + + assertThatThrownBy(() -> writer.writeStatus(tempDir.toFile(), ScenarioStatus.LISTED, List.of( + new ScenarioInfo("Feature", "Scenario: A", List.of())), false)) + .isInstanceOf(org.gradle.api.GradleException.class); + } +} diff --git a/gherkin-to-asciidoc/src/test/resources/templates/flat.mustache b/gherkin-to-asciidoc/src/test/resources/templates/flat.mustache new file mode 100644 index 0000000..0edd160 --- /dev/null +++ b/gherkin-to-asciidoc/src/test/resources/templates/flat.mustache @@ -0,0 +1,39 @@ += Feature Scenarios + +{{{intro}}} + +Every scenario is classified as exactly one of: + +[cols="1,3",options="header"] +|=== +| Status | Meaning + +{{#legend}} +| {{{status}}} +| {{{meaning}}} + +{{/legend}} +|=== + +== Progress Summary + +[cols="1,1,1",options="header"] +|=== +| Status | Count | Percentage + +{{#summary}} +| {{{status}}} +| {{count}} +| {{percentage}}% + +{{/summary}} +|=== + +{{#sections}} +== {{{status}}} + +{{{blurb}}} + +include::{{{snippet}}}[] + +{{/sections}} diff --git a/gherkin-to-asciidoc/src/test/resources/templates/grouped.mustache b/gherkin-to-asciidoc/src/test/resources/templates/grouped.mustache new file mode 100644 index 0000000..c957175 --- /dev/null +++ b/gherkin-to-asciidoc/src/test/resources/templates/grouped.mustache @@ -0,0 +1,47 @@ += Feature Scenarios + +{{{intro}}} + +Every scenario is classified as exactly one of: + +[cols="1,3",options="header"] +|=== +| Status | Meaning + +{{#legend}} +| {{{status}}} +| {{{meaning}}} + +{{/legend}} +|=== + +== Progress Summary + +[cols="1,1,1",options="header"] +|=== +| Status | Count | Percentage + +{{#summary}} +| {{{status}}} +| {{count}} +| {{percentage}}% + +{{/summary}} +|=== + +{{#sections}} +== {{{status}}} + +{{{blurb}}} + +{{#features}} +=== {{{title}}} + +include::{{{snippet}}}[] + +{{/features}} +{{^features}} +include::{{{snippet}}}[] + +{{/features}} +{{/sections}} From 3cf3a01ab30dabec27bab0263319c6d7ef7122ac Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Sat, 25 Jul 2026 01:06:06 +0400 Subject: [PATCH 2/3] docs(gherkin-to-asciidoc): add snippet-templates example demonstrating all 4 combinations Adds a new example project that registers four GenerateFeatureDocsTask instances directly in build.gradle - one per combination of groupByFeature (grouped/flat) and template (with/without) - all against the same auth + billing feature/glue content used by the progress-tracking example, so the four generated reports are easy to compare side by side. Bypassing the gherkinToAsciidoc { } extension like this only makes sense for demonstrating multiple configurations from one project; a real project only needs one. Includes the two reference templates from the plugin README (templates/grouped.mustache, templates/flat.mustache) copied verbatim, and a generateAllReports task that runs all four. Verified against a local publishToMavenLocal build before pinning the version catalog to 1.2.0 (the next version given this is an additive, non-breaking pair of properties on top of the released 1.1.0); won't build against the Gradle Plugin Portal until 1.2.0 is actually released - same caveat as previous example additions. Co-Authored-By: Claude Sonnet 5 --- ...in-to-asciidoc-snippet-templates-build.yml | 38 +++ examples/README.adoc | 2 + .../snippet-templates/README.adoc | 132 ++++++++++ .../snippet-templates/build.gradle | 84 ++++++ .../snippet-templates/gradle.properties | 8 + .../gradle/libs.versions.toml | 14 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + .../snippet-templates/gradlew | 248 ++++++++++++++++++ .../snippet-templates/gradlew.bat | 82 ++++++ .../snippet-templates/settings.gradle | 18 ++ .../example/auth/steps/LoginSteps.java | 32 +++ .../example/billing/steps/InvoiceSteps.java | 29 ++ .../features-auth/authentication.feature | 19 ++ .../features-billing/invoice.feature | 6 + .../snippet-templates/templates/flat.mustache | 39 +++ .../templates/grouped.mustache | 47 ++++ .../snippet-templates/versions.properties | 10 + 18 files changed, 817 insertions(+) create mode 100644 .github/workflows/example-gherkin-to-asciidoc-snippet-templates-build.yml create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/README.adoc create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/build.gradle create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/gradle.properties create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/gradle/libs.versions.toml create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/gherkin-to-asciidoc/snippet-templates/gradlew create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/gradlew.bat create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/settings.gradle create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/auth/steps/LoginSteps.java create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/billing/steps/InvoiceSteps.java create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-auth/authentication.feature create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-billing/invoice.feature create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/templates/flat.mustache create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/templates/grouped.mustache create mode 100644 examples/gherkin-to-asciidoc/snippet-templates/versions.properties diff --git a/.github/workflows/example-gherkin-to-asciidoc-snippet-templates-build.yml b/.github/workflows/example-gherkin-to-asciidoc-snippet-templates-build.yml new file mode 100644 index 0000000..d524cdd --- /dev/null +++ b/.github/workflows/example-gherkin-to-asciidoc-snippet-templates-build.yml @@ -0,0 +1,38 @@ +name: Example - Gherkin to AsciiDoc Snippet Templates Build +on: + push: + paths: + - 'examples/gherkin-to-asciidoc/snippet-templates/**/*.feature' + - 'examples/gherkin-to-asciidoc/snippet-templates/**/*.java' + - 'examples/gherkin-to-asciidoc/snippet-templates/**/*.mustache' + - 'examples/gherkin-to-asciidoc/snippet-templates/**/*.gradle' + - 'examples/gherkin-to-asciidoc/snippet-templates/**/*.properties' + - 'examples/gherkin-to-asciidoc/snippet-templates/**/libs.versions.toml' + - '.github/workflows/example-gherkin-to-asciidoc-snippet-templates-build.yml' + +jobs: + Build-Example: + name: Build Example + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Setup Java + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + with: + distribution: 'temurin' + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Build example + run: | + cd examples/gherkin-to-asciidoc/snippet-templates + chmod +x ./gradlew + ./gradlew generateAllReports build --no-daemon diff --git a/examples/README.adoc b/examples/README.adoc index 2b65ca6..8587561 100644 --- a/examples/README.adoc +++ b/examples/README.adoc @@ -33,3 +33,5 @@ include::jacoco-exclusion-report/dsl/README.adoc[leveloffset=+1] include::gherkin-to-asciidoc/plain/README.adoc[leveloffset=+1] include::gherkin-to-asciidoc/progress-tracking/README.adoc[leveloffset=+1] + +include::gherkin-to-asciidoc/snippet-templates/README.adoc[leveloffset=+1] diff --git a/examples/gherkin-to-asciidoc/snippet-templates/README.adoc b/examples/gherkin-to-asciidoc/snippet-templates/README.adoc new file mode 100644 index 0000000..25d56eb --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/README.adoc @@ -0,0 +1,132 @@ += Gherkin to AsciiDoc — Snippets and Templates Example +:toc: left + +== Description + +This example applies the Gherkin to AsciiDoc plugin against the same auth + billing feature/glue content used +in the xref:../progress-tracking/README.adoc[progress-tracking example], but registers **four** +`GenerateFeatureDocsTask` instances directly in `build.gradle` — one for every combination of `groupByFeature` +(grouped vs flat) and `template` (with vs without) — so all four outputs can be compared side by side. + +A real project only needs *one* of these, normally configured through the `gherkinToAsciidoc { }` extension. +This example bypasses the extension and registers the task type directly purely so it can demonstrate all +four combinations from a single project. + +== Intent + +This example shows: + +* That `snippetDir` and the `listed.adoc`/`defined.adoc`/`implemented.adoc` snippet files are always written + once `trackProgress` is enabled, regardless of whether a `template` is configured. +* How `groupByFeature` changes where those snippets are written: flat at `/.adoc`, or + split per feature at `//.adoc`. +* How setting `template` switches the generated document from embedding scenario titles verbatim to + referencing the snippets via `include::` directives. +* That the two reference templates documented in the + link:../../../gherkin-to-asciidoc/README.adoc[plugin README]'s "Report Snippets and Custom Templates" section + (`templates/grouped.mustache` and `templates/flat.mustache`, copied verbatim into this example) reproduce + - once their `include::` directives are resolved - exactly the same document as not using a template at all. + +== Configuration + +[source,groovy] +---- +def commonConfig = { GenerateFeatureDocsTask task -> + task.sourceDirs.from( + 'src/test/resources/features-auth', + 'src/test/resources/features-billing') + task.glueCodeDirs.from( + 'src/test/java/com/arc_e_tect/example/auth/steps', + 'src/test/java/com/arc_e_tect/example/billing/steps') + task.trackProgress.set(true) + task.includeSubDirs.set(true) + task.outputFileName.set('features.adoc') + task.projectDirectory.set(layout.projectDirectory) +} + +tasks.register('generateGroupedNoTemplate', GenerateFeatureDocsTask) { + commonConfig(it) + groupByFeature.set(true) + outputDir.set(layout.buildDirectory.dir('generated-docs/grouped-no-template')) + snippetDir.set(layout.buildDirectory.dir('generated-docs/grouped-no-template/features/snippets')) +} + +tasks.register('generateGroupedWithTemplate', GenerateFeatureDocsTask) { + commonConfig(it) + groupByFeature.set(true) + outputDir.set(layout.buildDirectory.dir('generated-docs/grouped-with-template')) + snippetDir.set(layout.buildDirectory.dir('generated-docs/grouped-with-template/features/snippets')) + template.set(file('templates/grouped.mustache')) +} + +// generateFlatNoTemplate / generateFlatWithTemplate mirror the above with groupByFeature = false +// and templates/flat.mustache. See build.gradle for the full listing. +---- + +== Build And Run + +[source,bash] +---- +cd examples/gherkin-to-asciidoc/snippet-templates +./gradlew generateAllReports +---- + +Or run any one combination individually, e.g. `./gradlew generateGroupedWithTemplate`. + +== What To Expect + +The build is expected to pass, writing four independent reports under `build/generated-docs/`. + +=== Grouped, no template — `grouped-no-template/features.adoc` + +Identical to the xref:../progress-tracking/README.adoc[progress-tracking example]'s output: scenario titles +are embedded verbatim, grouped by feature within each status. The snippet files are still written to +`grouped-no-template/features/snippets/`, split per feature (e.g. `userAuthentication/implemented.adoc`), even +though nothing in this report references them. + +=== Grouped, with template — `grouped-with-template/features.adoc` + +[source,asciidoc] +---- +== Implemented + +Scenarios whose every step has matching glue code. + +=== User authentication + +include::features/snippets/userAuthentication/implemented.adoc[] + +=== Invoice payment + +include::features/snippets/invoicePayment/implemented.adoc[] +---- + +Everything above `== Listed` (the intro, status legend, and progress summary table) is byte-for-byte identical +to the no-template report. Only the per-status content changes: each `=== ` heading is now followed +by an `include::` directive instead of the bullet list, pointing at exactly the snippet file +`groupByFeature` would have written for that feature and status. + +=== Flat, no template — `flat-no-template/features.adoc` + +Same as above, but every status is one flat bullet list with no `=== ` headings — the `auth` and +`billing` scenarios in `== Implemented` are listed together with no distinction between them. + +=== Flat, with template — `flat-with-template/features.adoc` + +[source,asciidoc] +---- +== Implemented + +Scenarios whose every step has matching glue code. + +include::features/snippets/implemented.adoc[] +---- + +One flat `include::` per status, referencing `flat-with-template/features/snippets/implemented.adoc`, which +itself contains: + +[source,asciidoc] +---- +* Scenario: User logs in successfully +* Scenario: User pays an invoice +---- diff --git a/examples/gherkin-to-asciidoc/snippet-templates/build.gradle b/examples/gherkin-to-asciidoc/snippet-templates/build.gradle new file mode 100644 index 0000000..de62f0a --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/build.gradle @@ -0,0 +1,84 @@ +import com.arc_e_tect.gradle.gherkin.GenerateFeatureDocsTask + +plugins { + id 'java' + alias(libs.plugins.gherkin.to.asciidoc) +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation libs.cucumber.java +} + +test { + failOnNoDiscoveredTests = false +} + +// This example bypasses the gherkinToAsciidoc { } extension entirely and instead registers four +// GenerateFeatureDocsTask instances directly, one per combination of groupByFeature and template, +// all against the same auth + billing feature/glue content, so the four reports are easy to compare. +// +// A real project only needs ONE of these - typically configured via the extension, not like this. + +def commonConfig = { GenerateFeatureDocsTask task -> + task.sourceDirs.from( + 'src/test/resources/features-auth', + 'src/test/resources/features-billing') + task.glueCodeDirs.from( + 'src/test/java/com/arc_e_tect/example/auth/steps', + 'src/test/java/com/arc_e_tect/example/billing/steps') + task.trackProgress.set(true) + // trackProgress implies recursive scanning regardless of this value, but the property still + // needs a value assigned since this example bypasses the extension's conventions. + task.includeSubDirs.set(true) + task.outputFileName.set('features.adoc') + task.projectDirectory.set(layout.projectDirectory) +} + +tasks.register('generateGroupedNoTemplate', GenerateFeatureDocsTask) { + commonConfig(it) + groupByFeature.set(true) + outputDir.set(layout.buildDirectory.dir('generated-docs/grouped-no-template')) + snippetDir.set(layout.buildDirectory.dir('generated-docs/grouped-no-template/features/snippets')) +} + +tasks.register('generateGroupedWithTemplate', GenerateFeatureDocsTask) { + commonConfig(it) + groupByFeature.set(true) + outputDir.set(layout.buildDirectory.dir('generated-docs/grouped-with-template')) + snippetDir.set(layout.buildDirectory.dir('generated-docs/grouped-with-template/features/snippets')) + template.set(file('templates/grouped.mustache')) +} + +tasks.register('generateFlatNoTemplate', GenerateFeatureDocsTask) { + commonConfig(it) + groupByFeature.set(false) + outputDir.set(layout.buildDirectory.dir('generated-docs/flat-no-template')) + snippetDir.set(layout.buildDirectory.dir('generated-docs/flat-no-template/features/snippets')) +} + +tasks.register('generateFlatWithTemplate', GenerateFeatureDocsTask) { + commonConfig(it) + groupByFeature.set(false) + outputDir.set(layout.buildDirectory.dir('generated-docs/flat-with-template')) + snippetDir.set(layout.buildDirectory.dir('generated-docs/flat-with-template/features/snippets')) + template.set(file('templates/flat.mustache')) +} + +tasks.register('generateAllReports') { + group = 'documentation' + description = 'Generates all four grouped/flat x template/no-template example reports.' + dependsOn tasks.named('generateGroupedNoTemplate'), + tasks.named('generateGroupedWithTemplate'), + tasks.named('generateFlatNoTemplate'), + tasks.named('generateFlatWithTemplate') +} diff --git a/examples/gherkin-to-asciidoc/snippet-templates/gradle.properties b/examples/gherkin-to-asciidoc/snippet-templates/gradle.properties new file mode 100644 index 0000000..4183829 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8 + +group=com.arc-e-tect +version = 0.0.1 diff --git a/examples/gherkin-to-asciidoc/snippet-templates/gradle/libs.versions.toml b/examples/gherkin-to-asciidoc/snippet-templates/gradle/libs.versions.toml new file mode 100644 index 0000000..bc8f39e --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/gradle/libs.versions.toml @@ -0,0 +1,14 @@ +## Generated by $ ./gradlew refreshVersionsCatalog + +[plugins] + +gherkin-to-asciidoc = { id = "com.arc-e-tect.gherkin-to-asciidoc", version.ref = "gherkin-to-asciidoc" } + +[versions] + +gherkin-to-asciidoc = "1.2.0" +cucumber-java = "7.22.1" + +[libraries] + +cucumber-java = { module = "io.cucumber:cucumber-java", version.ref = "cucumber-java" } diff --git a/examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.jar b/examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.properties b/examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a9db115 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/gherkin-to-asciidoc/snippet-templates/gradlew b/examples/gherkin-to-asciidoc/snippet-templates/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/gherkin-to-asciidoc/snippet-templates/gradlew.bat b/examples/gherkin-to-asciidoc/snippet-templates/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/examples/gherkin-to-asciidoc/snippet-templates/settings.gradle b/examples/gherkin-to-asciidoc/snippet-templates/settings.gradle new file mode 100644 index 0000000..aa3d8fb --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' + id "de.fayard.refreshVersions" version "0.60.6" +} + +refreshVersions { + rejectVersionIf { + candidate.stabilityLevel.isLessStableThan(current.stabilityLevel) + } +} + +rootProject.name = 'gherkin-to-asciidoc-snippet-templates-example' diff --git a/examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/auth/steps/LoginSteps.java b/examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/auth/steps/LoginSteps.java new file mode 100644 index 0000000..5c24164 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/auth/steps/LoginSteps.java @@ -0,0 +1,32 @@ +package com.arc_e_tect.example.auth.steps; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +/** + * Step definitions for the "User logs in successfully" scenario in + * {@code authentication.feature}. + * + *

Deliberately incomplete: the {@code Scenario Outline} steps + * ({@code the user submits "..." and "..."} / {@code the result is "..."}) have no + * matching step definition here, so {@code generateFeatureDocs} reports that + * scenario as {@code defined} rather than {@code implemented}.

+ */ +public class LoginSteps { + + @Given("the login page is open") + public void theLoginPageIsOpen() { + // Not needed to demonstrate the gherkin-to-asciidoc plugin. + } + + @When("the user submits valid credentials") + public void theUserSubmitsValidCredentials() { + // Not needed to demonstrate the gherkin-to-asciidoc plugin. + } + + @Then("the dashboard is displayed") + public void theDashboardIsDisplayed() { + // Not needed to demonstrate the gherkin-to-asciidoc plugin. + } +} diff --git a/examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/billing/steps/InvoiceSteps.java b/examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/billing/steps/InvoiceSteps.java new file mode 100644 index 0000000..7da7b55 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/src/test/java/com/arc_e_tect/example/billing/steps/InvoiceSteps.java @@ -0,0 +1,29 @@ +package com.arc_e_tect.example.billing.steps; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +/** + * Step definitions for the "User pays an invoice" scenario in + * {@code invoice.feature}, fully implemented to show that + * {@code generateFeatureDocs} aggregates glue code found across every + * directory in {@code glueCodeDirs}, not just one. + */ +public class InvoiceSteps { + + @Given("an outstanding invoice") + public void anOutstandingInvoice() { + // Not needed to demonstrate the gherkin-to-asciidoc plugin. + } + + @When("the user pays the invoice") + public void theUserPaysTheInvoice() { + // Not needed to demonstrate the gherkin-to-asciidoc plugin. + } + + @Then("the invoice is marked as paid") + public void theInvoiceIsMarkedAsPaid() { + // Not needed to demonstrate the gherkin-to-asciidoc plugin. + } +} diff --git a/examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-auth/authentication.feature b/examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-auth/authentication.feature new file mode 100644 index 0000000..c25f9e8 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-auth/authentication.feature @@ -0,0 +1,19 @@ +Feature: User authentication + + Scenario: User requests a password reset + # Not yet fleshed out - title only, no steps yet. + + Scenario: User logs in successfully + Given the login page is open + When the user submits valid credentials + Then the dashboard is displayed + + Scenario Outline: User logs in with different credential sets + Given the login page is open + When the user submits "" and "" + Then the result is "" + + Examples: + | username | password | outcome | + | alice | secret | success | + | bob | wrong | failure | diff --git a/examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-billing/invoice.feature b/examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-billing/invoice.feature new file mode 100644 index 0000000..b62bf83 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/src/test/resources/features-billing/invoice.feature @@ -0,0 +1,6 @@ +Feature: Invoice payment + + Scenario: User pays an invoice + Given an outstanding invoice + When the user pays the invoice + Then the invoice is marked as paid diff --git a/examples/gherkin-to-asciidoc/snippet-templates/templates/flat.mustache b/examples/gherkin-to-asciidoc/snippet-templates/templates/flat.mustache new file mode 100644 index 0000000..0edd160 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/templates/flat.mustache @@ -0,0 +1,39 @@ += Feature Scenarios + +{{{intro}}} + +Every scenario is classified as exactly one of: + +[cols="1,3",options="header"] +|=== +| Status | Meaning + +{{#legend}} +| {{{status}}} +| {{{meaning}}} + +{{/legend}} +|=== + +== Progress Summary + +[cols="1,1,1",options="header"] +|=== +| Status | Count | Percentage + +{{#summary}} +| {{{status}}} +| {{count}} +| {{percentage}}% + +{{/summary}} +|=== + +{{#sections}} +== {{{status}}} + +{{{blurb}}} + +include::{{{snippet}}}[] + +{{/sections}} diff --git a/examples/gherkin-to-asciidoc/snippet-templates/templates/grouped.mustache b/examples/gherkin-to-asciidoc/snippet-templates/templates/grouped.mustache new file mode 100644 index 0000000..c957175 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/templates/grouped.mustache @@ -0,0 +1,47 @@ += Feature Scenarios + +{{{intro}}} + +Every scenario is classified as exactly one of: + +[cols="1,3",options="header"] +|=== +| Status | Meaning + +{{#legend}} +| {{{status}}} +| {{{meaning}}} + +{{/legend}} +|=== + +== Progress Summary + +[cols="1,1,1",options="header"] +|=== +| Status | Count | Percentage + +{{#summary}} +| {{{status}}} +| {{count}} +| {{percentage}}% + +{{/summary}} +|=== + +{{#sections}} +== {{{status}}} + +{{{blurb}}} + +{{#features}} +=== {{{title}}} + +include::{{{snippet}}}[] + +{{/features}} +{{^features}} +include::{{{snippet}}}[] + +{{/features}} +{{/sections}} diff --git a/examples/gherkin-to-asciidoc/snippet-templates/versions.properties b/examples/gherkin-to-asciidoc/snippet-templates/versions.properties new file mode 100644 index 0000000..a07c552 --- /dev/null +++ b/examples/gherkin-to-asciidoc/snippet-templates/versions.properties @@ -0,0 +1,10 @@ +#### Dependencies and Plugin versions with their available updates. +#### Generated by `./gradlew refreshVersions` version 0.60.6 +#### +#### Don't manually edit or split the comments that start with four hashtags (####), +#### they will be overwritten by refreshVersions. +#### +#### suppress inspection "SpellCheckingInspection" for whole file +#### suppress inspection "UnusedProperty" for whole file +#### +#### NOTE: Some versions are filtered by the rejectVersionIf predicate. See the settings.gradle.kts file. From ba2597f95234ee66bc67b7adabcdf6f7e72d120a Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Sat, 25 Jul 2026 01:43:52 +0400 Subject: [PATCH 3/3] docs(gherkin-to-asciidoc): fix README template context docs and drop deprecated buildDir Explains what sections[].blurb means in the Mustache context table (previously only listed as a field name) and breaks down every sections field individually. Adds the "a" (AsciiDoc) column style so the new bullet list actually renders as a list instead of flat text. Also replaces the deprecated Groovy buildDir project property in the DSL default-value docs (outputDir, snippetDir) with layout.buildDirectory, matching what the plugin's own code and Kotlin DSL examples already used. Co-Authored-By: Claude Sonnet 5 --- gherkin-to-asciidoc/README.adoc | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/gherkin-to-asciidoc/README.adoc b/gherkin-to-asciidoc/README.adoc index 895df54..1edc4c9 100644 --- a/gherkin-to-asciidoc/README.adoc +++ b/gherkin-to-asciidoc/README.adoc @@ -80,8 +80,8 @@ gherkinToAsciidoc { includeSubDirs = false // Directory where the generated AsciiDoc file will be written. - // Default: "$buildDir/generated-docs" - outputDir = file("$buildDir/generated-docs") + // Default: build/generated-docs + outputDir = layout.buildDirectory.dir('generated-docs') // Name of the generated AsciiDoc file. // Default: 'features.adoc' @@ -106,8 +106,8 @@ gherkinToAsciidoc { // Directory that the listed.adoc/defined.adoc/implemented.adoc report snippets are written // to when trackProgress is true (see "Report Snippets and Custom Templates" below). - // Default: "$buildDir/generated-docs/features/snippets" - // snippetDir = file("$buildDir/generated-docs/features/snippets") + // Default: build/generated-docs/features/snippets + // snippetDir = layout.buildDirectory.dir('generated-docs/features/snippets') // Optional Mustache template used to render the report so it references the snippets via // include:: directives instead of embedding their content verbatim. Only consulted when @@ -144,7 +144,7 @@ Mutually exclusive with `sourceFile`. Mutually exclusive with `sourceDirs` and `includeSubDirs`. | `includeSubDirs`| Boolean | `false` | When `true`, recursively scans all sub-directories of every directory in `sourceDirs`. Cannot be combined with `sourceFile`. -| `outputDir` | Directory | `$buildDir/generated-docs` | Directory where the generated AsciiDoc file is written. +| `outputDir` | Directory | `build/generated-docs` | Directory where the generated AsciiDoc file is written. | `outputFileName`| String | `features.adoc` | Name of the generated AsciiDoc file. | `trackProgress` | Boolean | `false` | When `true`, classifies every scenario as `listed`, `defined`, or `implemented` and adds a progress summary to the generated document. Requires `sourceDirs` and `glueCodeDirs`; implies `includeSubDirs = true`. @@ -152,7 +152,7 @@ Requires `sourceDirs` and `glueCodeDirs`; implies `includeSubDirs = true`. Required when `trackProgress` is `true`. | `groupByFeature`| Boolean | `false` | When `true`, groups scenarios under their enclosing `Feature` instead of a flat list. Forced to `true` whenever `trackProgress` is `true`. -| `snippetDir` | Directory | `$buildDir/generated-docs/features/snippets` | Directory that the `listed.adoc`/`defined.adoc`/`implemented.adoc` report snippets are written to. +| `snippetDir` | Directory | `build/generated-docs/features/snippets` | Directory that the `listed.adoc`/`defined.adoc`/`implemented.adoc` report snippets are written to. Only used when `trackProgress` is `true`. | `template` | File | — | Optional Mustache template used to render the report so it references the snippets via `include::` directives instead of embedding their content verbatim. Only consulted when `trackProgress` is `true`. @@ -437,7 +437,7 @@ Setting `template` to a https://github.com/spullara/mustache.java[Mustache] file itself from embedding scenario titles verbatim to referencing the snippets via `include::` directives. The plugin renders `template` with the following context: -[cols="1,3",options="header"] +[cols="1,3a",options="header"] |=== | Variable | Description @@ -451,13 +451,17 @@ The plugin renders `template` with the following context: | List of `\{status, count, percentage}` - the three rows of the progress summary table. | `sections` -| List of `\{status, blurb, features, snippet}`, one per status, in Listed/Defined/Implemented order. +| List of `\{status, blurb, features, snippet}`, one per status, in Listed/Defined/Implemented order. Each entry has: -`features` is a list of `\{title, snippet}` when `groupByFeature` is `true` and the status has at -least one scenario; empty otherwise, in which case `snippet` holds the flat fallback path instead. +* `status` - the same display label as in `legend`, e.g. `Implemented`. +* `blurb` - the same one-sentence explanation as `legend`'s `meaning` for this status, repeated here so a + template can print it under the status heading without a separate lookup. +* `features` - a list of `\{title, snippet}` when `groupByFeature` is `true` and the status has at least one + scenario; empty otherwise, in which case `snippet` (below) holds the flat fallback path instead. +* `snippet` - the flat fallback snippet path, only meaningful when `features` is empty. -Every `snippet` path is already relative to the output file's directory, so a plain -+include::{{{snippet}}}[]+ resolves correctly. +Every `snippet` path (here and inside `features`) is already relative to the output file's directory, so a +plain +include::{{{snippet}}}[]+ resolves correctly. |=== [IMPORTANT]