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 0000000..b1b8ef5 Binary files /dev/null and b/examples/gherkin-to-asciidoc/snippet-templates/gradle/wrapper/gradle-wrapper.jar differ 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. diff --git a/gherkin-to-asciidoc/README.adoc b/gherkin-to-asciidoc/README.adoc index 93338d1..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' @@ -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: 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 + // 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")) } ---- @@ -131,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`. @@ -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 | `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`. |=== [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,169 @@ 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,3a",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. Each entry has: + +* `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 (here and inside `features`) 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}}