From b711179235d2dcacc51aa1f4bad8a6e9b6730974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederik=20B=C3=BClthoff?= <232148+frederikb@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:54:48 +0100 Subject: [PATCH] Add optional grouping of features by path in tree view Introduced an optional feature to group feature files by their directory paths in the tree view, providing a better overview of features for teams that organize them by functionality in nested directories. The default behavior remains unchanged, with features listed without grouping for backward compatibility. Added new configuration options: - `groupFeaturesByPath`: Enables or disables path-based grouping (default: false). - `removableBasePaths`: Specifies base paths to strip from feature file URIs before grouping. - `directoryNameFormatter`: Customizes how directory names are displayed in the grouped tree view. Fixes #366 --- CHANGELOG.md | 7 + .../cluecumber/core/CluecumberCore.java | 40 +++++ .../cluecumber/engine/CluecumberEngine.java | 30 ++++ .../engine/properties/PropertyManager.java | 111 +++++++++++- .../TreeViewPageCollection.java | 66 +++++-- .../pages/renderering/BasePaths.java | 106 +++++++++++ .../renderering/DirectoryNameFormatter.java | 132 ++++++++++++++ .../pages/renderering/PathComparator.java | 59 +++++++ .../pages/renderering/PathFormatter.java | 58 ++++++ .../pages/renderering/PathUtils.java | 58 ++++++ .../renderering/TreeViewPageRenderer.java | 17 +- .../resources/template/css/cluecumber.css | 32 +++- .../src/main/resources/template/tree-view.ftl | 59 ++++--- .../properties/PropertyManagerTest.java | 49 ++++- .../pojos/pagecollections/BasePathsTest.java | 127 +++++++++++++ .../pages/pojos/pagecollections/PojoTest.java | 2 +- .../DirectoryNameFormatterTest.java | 167 ++++++++++++++++++ .../pages/renderering/PathComparatorTest.java | 54 ++++++ .../pages/renderering/PathUtilsTest.java | 46 +++++ .../renderering/TreeViewPageRendererTest.java | 115 ++++++++++++ examples/core-example/pom.xml | 2 +- examples/maven-example/pom.xml | 12 +- .../cluecumber/maven/CluecumberMaven.java | 22 +++ pom.xml | 2 +- 24 files changed, 1314 insertions(+), 59 deletions(-) create mode 100644 engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/BasePaths.java create mode 100644 engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatter.java create mode 100644 engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparator.java create mode 100644 engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathFormatter.java create mode 100644 engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtils.java create mode 100644 engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/BasePathsTest.java create mode 100644 engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatterTest.java create mode 100644 engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparatorTest.java create mode 100644 engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtilsTest.java create mode 100644 engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRendererTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c991c393..eb99befe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. Back to [Readme](README.md). +## [3.11.0] - Work In Progress + +### Added + +* Optional grouping of features by their path in tree view + + ## [3.10.0] - 2025-01-06 ### Added diff --git a/core/src/main/java/com/trivago/cluecumber/core/CluecumberCore.java b/core/src/main/java/com/trivago/cluecumber/core/CluecumberCore.java index dbaf1c67..69e7c508 100644 --- a/core/src/main/java/com/trivago/cluecumber/core/CluecumberCore.java +++ b/core/src/main/java/com/trivago/cluecumber/core/CluecumberCore.java @@ -22,6 +22,7 @@ import com.trivago.cluecumber.engine.logging.CluecumberLogger; import java.util.LinkedHashMap; +import java.util.Set; /** * The main Cluecumber core class that passes properties to the Cluecumber engine. @@ -48,6 +49,9 @@ private CluecumberCore(final Builder builder) throws CluecumberException { cluecumberEngine.setCustomStatusColorFailed(builder.customStatusColorFailed); cluecumberEngine.setCustomStatusColorPassed(builder.customStatusColorPassed); cluecumberEngine.setCustomStatusColorSkipped(builder.customStatusColorSkipped); + cluecumberEngine.setGroupFeaturesByPath(builder.groupFeaturesByPath); + cluecumberEngine.setRemovableBasePaths(builder.removableBasePaths); + cluecumberEngine.setDirectoryNameFormatter(builder.directoryNameFormatter); cluecumberEngine.setExpandSubSections(builder.expandSubSections); cluecumberEngine.setExpandAttachments(builder.expandAttachments); cluecumberEngine.setExpandBeforeAfterHooks(builder.expandBeforeAfterHooks); @@ -86,6 +90,9 @@ public static class Builder { private String customStatusColorFailed; private String customStatusColorPassed; private String customStatusColorSkipped; + private Set removableBasePaths; + private String directoryNameFormatter; + private boolean groupFeaturesByPath; private boolean expandSubSections; private boolean expandAttachments; private boolean expandBeforeAfterHooks; @@ -214,6 +221,39 @@ public Builder setCustomStatusColorSkipped(final String customStatusColorSkipped return this; } + /** + * Whether to group features by path in the tree view. + * + * @param groupFeaturesByPath If true, the tree view will group features by their directory paths. + * @return The {@link Builder}. + */ + public Builder setGroupFeaturesByPath(final boolean groupFeaturesByPath) { + this.groupFeaturesByPath = groupFeaturesByPath; + return this; + } + + /** + * Set the base paths to be removed from feature file URIs before grouping. + * + * @param removableBasePaths A set of strings representing the base paths. + * @return The {@link Builder}. + */ + public Builder setRemovableBasePaths(final Set removableBasePaths) { + this.removableBasePaths = removableBasePaths; + return this; + } + + /** + * Set the directory name formatter for customizing directory names. + * + * @param directoryNameFormatter The fully qualified class name of the formatter implementation. + * @return The {@link Builder}. + */ + public Builder setDirectoryNameFormatter(final String directoryNameFormatter) { + this.directoryNameFormatter = directoryNameFormatter; + return this; + } + /** * Whether to expand subsections or not. * diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/CluecumberEngine.java b/engine/src/main/java/com/trivago/cluecumber/engine/CluecumberEngine.java index 844c3ead..1c3df635 100644 --- a/engine/src/main/java/com/trivago/cluecumber/engine/CluecumberEngine.java +++ b/engine/src/main/java/com/trivago/cluecumber/engine/CluecumberEngine.java @@ -36,6 +36,7 @@ import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.List; +import java.util.Set; import static com.trivago.cluecumber.engine.logging.CluecumberLogger.CluecumberLogLevel.COMPACT; import static com.trivago.cluecumber.engine.logging.CluecumberLogger.CluecumberLogLevel.DEFAULT; @@ -197,6 +198,35 @@ public void setCustomNavigationLinks(final LinkedHashMap customN propertyManager.setCustomNavigationLinks(customNavigationLinks); } + /** + * Set whether the report should group feature files by their path in the tree view. + * + * @param groupFeaturesByPath true to enable path-based tree view, false otherwise. + */ + public void setGroupFeaturesByPath(final boolean groupFeaturesByPath) { + propertyManager.setGroupFeaturesByPath(groupFeaturesByPath); + } + + /** + * Set the base paths to be removed from feature file URIs when used in grouping. + * + * @param removableBasePaths A set of strings representing the base paths. + * @throws WrongOrMissingPropertyException If the paths are invalid. + */ + public void setRemovableBasePaths(final Set removableBasePaths) throws WrongOrMissingPropertyException { + propertyManager.setRemovableBasePaths(removableBasePaths); + } + + /** + * Set the directory name formatter. + * + * @param formatterClassName The fully qualified class name of the formatter implementation. + * @throws WrongOrMissingPropertyException Thrown if the class is invalid or missing. + */ + public void setDirectoryNameFormatter(final String formatterClassName) throws WrongOrMissingPropertyException { + propertyManager.setDirectoryNameFormatter(formatterClassName); + } + /** * Whether to fail scenarios when steps are pending or undefined. * diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/properties/PropertyManager.java b/engine/src/main/java/com/trivago/cluecumber/engine/properties/PropertyManager.java index eb53dd34..c80205e7 100644 --- a/engine/src/main/java/com/trivago/cluecumber/engine/properties/PropertyManager.java +++ b/engine/src/main/java/com/trivago/cluecumber/engine/properties/PropertyManager.java @@ -24,15 +24,15 @@ import com.trivago.cluecumber.engine.logging.CluecumberLogger; import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.Link; import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.LinkType; +import com.trivago.cluecumber.engine.rendering.pages.renderering.BasePaths; +import com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter; import javax.inject.Inject; import javax.inject.Singleton; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; +import java.nio.file.Path; +import java.util.*; import java.util.regex.Pattern; +import java.util.stream.Collectors; import static com.trivago.cluecumber.engine.logging.CluecumberLogger.CluecumberLogLevel.COMPACT; import static com.trivago.cluecumber.engine.logging.CluecumberLogger.CluecumberLogLevel.DEFAULT; @@ -52,6 +52,9 @@ public class PropertyManager { private final Map customNavigationLinks = new LinkedHashMap<>(); private String sourceJsonReportDirectory; private String generatedHtmlReportDirectory; + private DirectoryNameFormatter directoryNameFormatter = new DirectoryNameFormatter.Standard(); + private BasePaths basePaths = new BasePaths(Set.of()); + private boolean groupFeaturesByPath = false; private boolean failScenariosOnPendingOrUndefinedSteps = false; private boolean expandSubSections = false; private boolean expandBeforeAfterHooks = false; @@ -137,6 +140,101 @@ public void setGeneratedHtmlReportDirectory(final String generatedHtmlReportDire this.generatedHtmlReportDirectory = generatedHtmlReportDirectory; } + /** + * Gets the {@link BasePaths} instance containing the set of paths which will be removed from feature file URIs before grouping. + * + * @return The {@link BasePaths} instance. + */ + public BasePaths getRemovableBasePaths() { + return basePaths; + } + + /** + * Sets the paths which will be removed from feature file URIs when used in grouping. + * + * @param removableBasePaths A set of strings representing the base paths. + * @throws WrongOrMissingPropertyException If the paths are invalid. + */ + public void setRemovableBasePaths(Set removableBasePaths) throws WrongOrMissingPropertyException { + if (removableBasePaths == null) { + return; + } + try { + Set paths = removableBasePaths.stream() + .map(Path::of) + .collect(Collectors.toSet()); + this.basePaths = new BasePaths(paths); + } catch (Exception e) { + logger.warn("Invalid base path(s) provided: " + removableBasePaths); + throw new WrongOrMissingPropertyException("basePaths"); + } + } + + /** + * Get the currently configured directory name formatter. + * + * @return The {@link DirectoryNameFormatter} instance. + */ + public DirectoryNameFormatter getDirectoryNameFormatter() { + return directoryNameFormatter; + } + + /** + * Set the directory name formatter based on the class name of a formatter implementation. + * + *

The provided class name must refer to a class that exists, is accessible, and implements the + * {@link DirectoryNameFormatter} interface. + * + *

Examples of valid values: + *

    + *
  • {@code com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter$CamelCase}
  • + *
  • {@code com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter$SnakeCase}
  • + *
  • {@code com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter$KebabCase}
  • + *
+ * + * @param formatterClassName The fully qualified class name of the formatter implementation. + * @throws WrongOrMissingPropertyException Thrown if the class name is invalid or missing. + */ + public void setDirectoryNameFormatter(String formatterClassName) throws WrongOrMissingPropertyException { + if (!isSet(formatterClassName)) { + return; + } + try { + Class clazz = Class.forName(formatterClassName); + if (!DirectoryNameFormatter.class.isAssignableFrom(clazz)) { + logger.warn("The class '" + formatterClassName + "' does not implement DirectoryNameFormatter"); + throw new WrongOrMissingPropertyException("directoryNameFormatter"); + } + directoryNameFormatter = (DirectoryNameFormatter) clazz.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException e) { + logger.warn("The class '" + formatterClassName + "' was not found"); + throw new WrongOrMissingPropertyException("directoryNameFormatter"); + } catch (Exception e) { + logger.warn("An error occurred while setting directoryNameFormatter to '" + formatterClassName + "': " + e.getMessage()); + throw new WrongOrMissingPropertyException("directoryNameFormatter"); + } + } + + /** + * This determines whether the tree view page of feature files will group the features by their URI path. + * + * @return {@code true} if the tree view should use paths to group, {@code false} otherwise. + */ + public boolean isGroupFeaturesByPath() { + return groupFeaturesByPath; + } + + /** + * Sets whether the tree view page of feature files will group the features by their URI path. + * + *

Setting this to false (the default) keeps the original behavior. + * + * @param groupFeaturesByPath {@code true} to enable grouping by path in the tree view, {@code false} to disable it. + */ + public void setGroupFeaturesByPath(final boolean groupFeaturesByPath) { + this.groupFeaturesByPath = groupFeaturesByPath; + } + /** * Get the custom parameters to be shown at the top of the report. * @@ -571,6 +669,9 @@ public void logProperties() { logger.info("- custom parameters display mode : " + customParametersDisplayMode, DEFAULT); logger.info("- group previous scenario runs : " + groupPreviousScenarioRuns, DEFAULT); logger.info("- expand previous scenario runs : " + expandPreviousScenarioRuns, DEFAULT); + logger.info("- group features by path : " + groupFeaturesByPath, DEFAULT); + logger.info("- directory name formatter : " + directoryNameFormatter.getClass().getName(), DEFAULT); + logger.info("- removable base paths : " + basePaths.getBasePaths().stream().map(Path::toString).collect(Collectors.joining(", ")), DEFAULT); if (!customNavigationLinks.isEmpty()) { customNavigationLinks.entrySet().stream().map( diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/TreeViewPageCollection.java b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/TreeViewPageCollection.java index 13d52950..f4d9f198 100644 --- a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/TreeViewPageCollection.java +++ b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/TreeViewPageCollection.java @@ -17,34 +17,78 @@ import com.trivago.cluecumber.engine.json.pojo.Element; import com.trivago.cluecumber.engine.rendering.pages.pojos.Feature; +import com.trivago.cluecumber.engine.rendering.pages.renderering.PathFormatter; +import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Set; /** * Page collection for the tree view page. */ public class TreeViewPageCollection extends PageCollection { - private final Map> elements; + private final Set paths; + private final Map> featuresByPath; + private final PathFormatter pathFormatter; + + public static class FeatureAndScenarios { + private final Feature feature; + private final List scenarios; + + public FeatureAndScenarios(Feature feature, List scenarios) { + this.feature = feature; + this.scenarios = scenarios; + } + + public Feature getFeature() { + return feature; + } + + public List getScenarios() { + return scenarios; + } + } /** * Constructor. * - * @param elements The map of features and associated scenarios. - * @param pageTitle The title of the tree view page. + * @param paths The paths of the features. + * @param featuresByPath The map of paths and associated features. + * @param pathFormatter The formatter used to display paths. + * @param pageTitle The title of the tree view page. */ - public TreeViewPageCollection(final Map> elements, final String pageTitle) { + public TreeViewPageCollection(final Set paths, final Map> featuresByPath, PathFormatter pathFormatter, final String pageTitle) { super(pageTitle); - this.elements = elements; + this.paths = paths; + this.featuresByPath = featuresByPath; + this.pathFormatter = pathFormatter; + } + + /** + * Get the paths of the features. + * + * @return The paths of the features. + */ + public Set getPaths() { + return paths; } /** - * Get the list of features and their scenarios. + * Get the map of paths and their features. * - * @return The map of features and associated scenarios. + * @return The map of paths and associated features. + */ + public Map> getFeaturesByPath() { + return featuresByPath; + } + + /** + * Get the formatter used to turn the path into a displayable title. + * @return The formatter used for paths. */ - public Map> getElements() { - return elements; + public PathFormatter getPathFormatter() { + return pathFormatter; } /** @@ -53,7 +97,7 @@ public Map> getElements() { * @return The count. */ public int getNumberOfFeatures() { - return elements.size(); + return featuresByPath.values().stream().mapToInt(List::size).sum(); } /** @@ -62,7 +106,7 @@ public int getNumberOfFeatures() { * @return The count. */ public int getNumberOfScenarios() { - return elements.values().stream().mapToInt(List::size).sum(); + return featuresByPath.values().stream().flatMap(List::stream).map(FeatureAndScenarios::getScenarios).mapToInt(List::size).sum(); } } diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/BasePaths.java b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/BasePaths.java new file mode 100644 index 00000000..9e639c68 --- /dev/null +++ b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/BasePaths.java @@ -0,0 +1,106 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import com.trivago.cluecumber.engine.rendering.pages.pojos.Feature; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A set of potential base paths of feature file URIs. + * + *

These base paths can be stripped from the {@link Feature#getUri()} of {@link com.trivago.cluecumber.engine.rendering.pages.pojos.Feature} when presenting them hierarchically. + *

Example: {@code "/features/customer/registration.feature"} → {@code "/customer/registration.feature"}. + */ +public class BasePaths { + private final Set normalizedBasePaths; + + /** + * Constructs a BasePaths object + * + * @param basePaths The collection of base paths + */ + public BasePaths(Collection basePaths) { + Objects.requireNonNull(basePaths, "Base paths cannot be null"); + this.normalizedBasePaths = basePaths.stream() + .map(BasePaths::normalizePath) + .collect(Collectors.toSet()); + } + + /** + * Constructs a BasePaths object from a collection of strings, representing the paths. + * + * @param basePaths The collection of base paths + * @return the newly initialized instance + */ + public static BasePaths fromStrings(Collection basePaths) { + Set paths = Objects.requireNonNull(basePaths, "Base paths cannot be null").stream() + .map(Path::of) + .collect(Collectors.toSet()); + return new BasePaths(paths); + } + + /** + * Normalizes a path and treats it as "absolute" for comparison purposes. + * + * @param path The path to normalize. + * @return The normalized "absolute" path. + */ + private static Path normalizePath(Path path) { + String pathString = path.toString(); + if (!pathString.startsWith("/")) { + pathString = "/" + pathString; + } + return Path.of(pathString).normalize(); + } + + /** + * Gets the normalized base paths. + * + * @return An unmodifiable list of normalized base paths. + */ + public Set getBasePaths() { + return Collections.unmodifiableSet(normalizedBasePaths); + } + + /** + * Finds the longest matching base path for the given path and strips it. + * + * @param path The {@link Path} to process. + * @return The stripped path as a {@link Path}, or {@code "/"} if the resulting path is empty. + */ + public Path stripBasePath(Path path) { + Path normalizedPath = normalizePath(Objects.requireNonNull(path, "Path cannot be null")); + + Path bestMatch = null; + for (Path basePath : normalizedBasePaths) { + if (normalizedPath.startsWith(basePath) && (bestMatch == null || basePath.toString().length() > bestMatch.toString().length())) { + bestMatch = basePath; + } + } + + if (bestMatch != null) { + return normalizePath(bestMatch.relativize(normalizedPath)); + } + + return normalizedPath; + } +} \ No newline at end of file diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatter.java b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatter.java new file mode 100644 index 00000000..91184b25 --- /dev/null +++ b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatter.java @@ -0,0 +1,132 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import java.util.Arrays; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * {@code DirectoryNameFormatter} defines the SPI for converting directory names + * to a display name. + * + *

Implementations of this interface are used to dynamically format directory names in the URI of feature files when presented in reports. + * + *

Built-in Implementations

+ *
    + *
  • {@link Standard}
  • + *
  • {@link SnakeCase}
  • + *
  • {@link CamelCase}
  • + *
  • {@link KebabCase}
  • + *
+ */ +public interface DirectoryNameFormatter { + + /** + * Converts a directory name into the display name based on the specific naming convention. + * + * @param name the original directory name, never {@code null} + * @return the formatted directory name + */ + String toDisplayName(String name); + + /** + * {@code Standard} formatter that uses the input name as-is without modification. + */ + class Standard implements DirectoryNameFormatter { + + @Override + public String toDisplayName(String name) { + return Objects.requireNonNull(name); + } + } + + /** + * {@code Snake Case} formatter that converts snake_case names into Title Case. + * + *

Example: {@code "my_directory_name"} → {@code "My Directory Name"}. + * + *

Caveat: Minor words are capitalized as well, contrary to common Title Case standards. + * {@code "an_introduction_to_programming_with_java"} → {@code "An Introduction To Programming With Java"} instead of {@code "An Introduction to Programming with Java"}. + */ + class SnakeCase implements DirectoryNameFormatter { + + @Override + public String toDisplayName(String name) { + if (Objects.requireNonNull(name).isEmpty()) { + return name; + } + return Arrays.stream(name.split("_")) + .map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1)) + .collect(Collectors.joining(" ")); + } + } + + /** + * {@code CamelCase} formatter that converts camelCase names into Title Case. + * + *

Example: {@code "myDirectoryName"} → {@code "My Directory Name"}. + * + *

Caveat: Minor words are capitalized as well, contrary to common Title Case standards. + * {@code "anIntroductionToProgrammingWithJava"} → {@code "An Introduction To Programming With Java"} instead of {@code "An Introduction to Programming with Java"}. + */ + class CamelCase implements DirectoryNameFormatter { + + @Override + public String toDisplayName(String name) { + if (Objects.requireNonNull(name).isEmpty()) { + return name; + } + return capitalizeWords(name.replaceAll( + "(?<=\\p{Lu})(?=\\p{Lu}\\p{Ll})" + + "|" + + "(?<=[^\\p{Lu}])(?=\\p{Lu})" + + "|" + + "(?<=\\d)(?=\\p{Ll})" + + "|" + + "(?<=\\p{L})(?=[^A-Za-z])", + " ")); + } + + private String capitalizeWords(String text) { + return Arrays.stream(text.split("\\s+")) + .map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1)) + .collect(Collectors.joining(" ")); + } + + } + + /** + * {@code KebabCase} formatter that converts kebab-case names into Title Case. + * + *

Example: {@code "my-directory-name"} → {@code "My Directory Name"}. + * + *

Caveat: Minor words are capitalized as well, contrary to common Title Case standards. + * {@code "an-introduction-to-programming-with-java"} → {@code "An Introduction To Programming With Java"} instead of {@code "An Introduction to Programming with Java"}. + */ + class KebabCase implements DirectoryNameFormatter { + + @Override + public String toDisplayName(String name) { + if (Objects.requireNonNull(name).isEmpty()) { + return name; + } + return Arrays.stream(name.split("-")) + .map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1)) + .collect(Collectors.joining(" ")); + } + } +} \ No newline at end of file diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparator.java b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparator.java new file mode 100644 index 00000000..7e1634f8 --- /dev/null +++ b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparator.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import java.nio.file.Path; +import java.util.Comparator; + +/** + * A comparator for {@link Path} objects that compares paths based on the following rules: + *

    + *
  • If one path is a prefix of the other, the shorter path is considered "less than" the longer path.
  • + *
  • Otherwise, paths are compared alphabetically based on their string representations.
  • + *
+ * + *

This comparator is useful when paths need to be sorted in a way that respects both prefix relationships + * and lexicographical order. + * For example, this ensures that a parent directory appears before its subdirectories + * in a sorted list.

+ * + *

Example usage:

+ *
+ * List<Path> paths = List.of(
+ *     Path.of("/a/b"),
+ *     Path.of("/a"),
+ *     Path.of("/b")
+ * );
+ * paths.sort(new PathComparator());
+ * // Result: ["/a", "/a/b", "/b"]
+ * 
+ */ +public class PathComparator implements Comparator { + + @Override + public int compare(Path path1, Path path2) { + String p1 = path1.toString(); + String p2 = path2.toString(); + + // If one path is a prefix of the other, prioritize the shorter path + if (p1.startsWith(p2) || p2.startsWith(p1)) { + return Integer.compare(p1.length(), p2.length()); + } + + // Otherwise, sort alphabetically + return p1.compareTo(p2); + } +} \ No newline at end of file diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathFormatter.java b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathFormatter.java new file mode 100644 index 00000000..a5a55733 --- /dev/null +++ b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathFormatter.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Formats a {@link Path} for display using a {@link DirectoryNameFormatter} for each element. + */ +public class PathFormatter { + + private static final String ROOT_PATH = "/"; + + private final DirectoryNameFormatter directoryNameFormatter; + + /** + * Constructor + * + * @param directoryNameFormatter The formatter to use for formatting each element of the path. + */ + public PathFormatter(DirectoryNameFormatter directoryNameFormatter) { + this.directoryNameFormatter = directoryNameFormatter; + } + + /** + * Formats the path using the configured {@link DirectoryNameFormatter}. + * + *

Example (using {@link com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter.KebabCase}):

+ * {@code Path.of("/product-list/top-rated")} → {@code "Product List / Top Rated"}. + * + * @param path The path to be formatted + * @return The string to be displayed + */ + public String formatPath(Path path) { + if (Objects.requireNonNull(path).getNameCount() == 0) { + return ROOT_PATH; + } + return StreamSupport.stream(path.spliterator(), false).map(Path::toString) + .map(directoryNameFormatter::toDisplayName) + .collect(Collectors.joining(" / ")); + } +} diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtils.java b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtils.java new file mode 100644 index 00000000..1527e707 --- /dev/null +++ b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtils.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class PathUtils { + + private PathUtils() { + } + + /** + * Extracts and normalizes the parent directory path from a URI or path string. + * + *

Returns {@code "/" } if there is no parent directory. + * + * @param uriString The URI or path string. + * @return The normalized parent directory as a {@link Path}, or {@code "/" } if there is no parent. + */ + public static Path extractDirectoryPath(String uriString) { + try { + URI uri = new URI(uriString); + String rawPath = "classpath".equalsIgnoreCase(uri.getScheme()) + ? uri.getSchemeSpecificPart() + : uri.getPath(); + + if (rawPath == null) { + // Handle cases where the path is null (e.g., malformed file URIs) + rawPath = uri.getSchemeSpecificPart(); + } + + Path normalizedPath = Paths.get(rawPath).normalize(); + Path parent = normalizedPath.getParent(); + return parent == null ? Path.of("/") : parent; + } catch (URISyntaxException | IllegalArgumentException e) { + // Fallback for invalid URIs or plain paths + Path normalizedPath = Paths.get(uriString).normalize(); + Path parent = normalizedPath.getParent(); + return parent == null ? Path.of("/") : parent; + } + } +} diff --git a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRenderer.java b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRenderer.java index d4f91797..282b97bd 100644 --- a/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRenderer.java +++ b/engine/src/main/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRenderer.java @@ -16,22 +16,24 @@ package com.trivago.cluecumber.engine.rendering.pages.renderering; import com.trivago.cluecumber.engine.exceptions.CluecumberException; -import com.trivago.cluecumber.engine.json.pojo.Element; import com.trivago.cluecumber.engine.properties.PropertyManager; import com.trivago.cluecumber.engine.rendering.pages.pojos.Feature; import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.AllFeaturesPageCollection; import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.AllScenariosPageCollection; import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.TreeViewPageCollection; +import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.TreeViewPageCollection.FeatureAndScenarios; import freemarker.template.Template; import javax.inject.Inject; import javax.inject.Singleton; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.Comparator; -import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.stream.Collectors; /** @@ -40,6 +42,7 @@ @Singleton public class TreeViewPageRenderer extends PageRenderer { + private static final Path ROOT_PATH = Path.of("/"); private final PropertyManager propertyManager; /** @@ -69,17 +72,21 @@ public String getRenderedContent( final Template template) throws CluecumberException { - Map> scenariosPerFeatures = new LinkedHashMap<>(); Set features = allFeaturesPageCollection.getFeatures() .stream().sorted(Comparator.comparing(Feature::getName)) .collect(Collectors.toCollection(LinkedHashSet::new)); + + BasePaths basePaths = propertyManager.getRemovableBasePaths(); + Map> featuresByPath = new TreeMap<>(new PathComparator()); for (Feature feature : features) { - scenariosPerFeatures.put(feature, allScenariosPageCollection.getElementsByFeatureIndex(feature.getIndex())); + Path directoryPath = propertyManager.isGroupFeaturesByPath() ? basePaths.stripBasePath(PathUtils.extractDirectoryPath(feature.getUri())) : ROOT_PATH; + featuresByPath.computeIfAbsent(directoryPath, k -> new ArrayList<>()).add(new FeatureAndScenarios(feature, allScenariosPageCollection.getElementsByFeatureIndex(feature.getIndex()))); } + Set paths = featuresByPath.keySet().stream().sorted(new PathComparator()).collect(Collectors.toCollection(LinkedHashSet::new)); return processedContent( template, - new TreeViewPageCollection(scenariosPerFeatures, allFeaturesPageCollection.getPageTitle()), + new TreeViewPageCollection(paths, featuresByPath, new PathFormatter(propertyManager.getDirectoryNameFormatter()), allFeaturesPageCollection.getPageTitle()), propertyManager.getNavigationLinks() ); } diff --git a/engine/src/main/resources/template/css/cluecumber.css b/engine/src/main/resources/template/css/cluecumber.css index 1c0eca63..42222234 100644 --- a/engine/src/main/resources/template/css/cluecumber.css +++ b/engine/src/main/resources/template/css/cluecumber.css @@ -80,13 +80,31 @@ a { text-align: center; } -#tree-view li { - list-style-type: none; - text-align: left; -} - -#tree-view ul li { - margin-bottom: .4rem; +#tree-view { + h4 { + text-align: left; + background-color: #ddd; + padding: 0 0 0 0.5rem; + line-height: 2.5rem; + border-radius: calc(.25rem - 1px) calc(.25rem - 1px) 0 0; + } + + h4:not(:first-child) { + margin-top: 2rem; + } + + li { + list-style-type: none; + text-align: left; + } + + ul { + padding-inline-start: 0; + + li { + margin-bottom: .4rem; + } + } } .embedding-content { diff --git a/engine/src/main/resources/template/tree-view.ftl b/engine/src/main/resources/template/tree-view.ftl index 0e78e4b8..26963a37 100644 --- a/engine/src/main/resources/template/tree-view.ftl +++ b/engine/src/main/resources/template/tree-view.ftl @@ -30,31 +30,40 @@ preheadlineLink="">

<@page.card width="12" title="${numberOfFeatures} ${common.pluralizeFn('Feature', numberOfFeatures)} with ${numberOfScenarios} ${common.pluralizeFn('Scenario', numberOfScenarios)}" subtitle="" classes=""> -
    - <#list elements as feature, scenarios> - <#assign tooltipText = ""> - <#if feature.description?has_content> - <#assign tooltipText = "${feature.description} | "> - - <#assign tooltipText = "${tooltipText}${feature.uri}"> -
  • - - ${feature.name?html} - -
  • -
      - <#list scenarios as scenario> - <#if ((!scenario.isMultiRunParent() && !scenario.isMultiRunChild()) || scenario.isMultiRunParent()) > -
    1. ${scenario.name?html} -
    2. - - -
    -
    - -
+ <#list featuresByPath as path, features> + <#if featuresByPath?size > 1 && path != "/"> +

${pathFormatter.formatPath(path)}

+ +
    + <#list features as featureWithScenarios> + <#assign feature = featureWithScenarios.feature> + <#assign scenarios = featureWithScenarios.scenarios> + <#assign tooltipText = ""> + <#if feature.description?has_content> + <#assign tooltipText = "${feature.description} | "> + + <#assign tooltipText = "${tooltipText}${feature.uri}"> +
  • + + ${feature.name?html} + +
  • +
      + <#list scenarios as scenario> + <#if ((!scenario.isMultiRunParent() && !scenario.isMultiRunChild()) || scenario.isMultiRunParent()) > +
    1. ${scenario.name?html} +
    2. + + +
    + <#if featureWithScenarios?has_next> +
    + + +
+
diff --git a/engine/src/test/java/com/trivago/cluecumber/engine/properties/PropertyManagerTest.java b/engine/src/test/java/com/trivago/cluecumber/engine/properties/PropertyManagerTest.java index fcd379f7..f46e381a 100644 --- a/engine/src/test/java/com/trivago/cluecumber/engine/properties/PropertyManagerTest.java +++ b/engine/src/test/java/com/trivago/cluecumber/engine/properties/PropertyManagerTest.java @@ -6,6 +6,7 @@ import com.trivago.cluecumber.engine.exceptions.properties.WrongOrMissingPropertyException; import com.trivago.cluecumber.engine.filesystem.FileIO; import com.trivago.cluecumber.engine.logging.CluecumberLogger; +import com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -65,7 +66,7 @@ public void logBasePropertiesTest() { verify(logger, times(2)).info(anyString(), eq(CluecumberLogger.CluecumberLogLevel.DEFAULT), eq(CluecumberLogger.CluecumberLogLevel.COMPACT)); - verify(logger, times(13)).info(anyString(), + verify(logger, times(16)).info(anyString(), eq(CluecumberLogger.CluecumberLogLevel.DEFAULT)); } @@ -211,6 +212,50 @@ public void customColorsTest() throws WrongOrMissingPropertyException { assertEquals(propertyManager.getCustomStatusColorSkipped(), "#cccccc"); } + @Test + public void usePathTreeViewDefaultValueTest() { + assertFalse(propertyManager.isGroupFeaturesByPath()); + } + + @Test + public void setGroupFeaturesByPathToTrueTest() { + propertyManager.setGroupFeaturesByPath(true); + assertTrue(propertyManager.isGroupFeaturesByPath()); + } + + @Test + public void setGroupFeaturesByPathToFalseTest() { + propertyManager.setGroupFeaturesByPath(true); + propertyManager.setGroupFeaturesByPath(false); + assertFalse(propertyManager.isGroupFeaturesByPath()); + } + + @Test + public void directoryNameFormatterDefaultValueTest() { + assertTrue(propertyManager.getDirectoryNameFormatter() instanceof DirectoryNameFormatter.Standard); + } + + @Test + public void setValidDirectoryNameFormatterTest() throws WrongOrMissingPropertyException { + propertyManager.setDirectoryNameFormatter( + "com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter$CamelCase"); + assertTrue(propertyManager.getDirectoryNameFormatter() instanceof DirectoryNameFormatter.CamelCase); + } + + @Test + public void setInvalidDirectoryNameFormatterClassNameTest() { + assertThrows(WrongOrMissingPropertyException.class, () -> { + propertyManager.setDirectoryNameFormatter("com.example.InvalidFormatter"); + }); + } + + @Test + public void setNonImplementingClassDirectoryNameFormatterTest() { + assertThrows(WrongOrMissingPropertyException.class, () -> { + propertyManager.setDirectoryNameFormatter("java.lang.String"); + }); + } + @Test public void logFullPropertiesTest() throws MissingFileException { Map customParameters = new HashMap<>(); @@ -225,7 +270,7 @@ public void logFullPropertiesTest() throws MissingFileException { verify(logger, times(2)).info(anyString(), eq(CluecumberLogger.CluecumberLogLevel.DEFAULT), eq(CluecumberLogger.CluecumberLogLevel.COMPACT)); - verify(logger, times(16)).info(anyString(), + verify(logger, times(19)).info(anyString(), eq(CluecumberLogger.CluecumberLogLevel.DEFAULT)); } } diff --git a/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/BasePathsTest.java b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/BasePathsTest.java new file mode 100644 index 00000000..d2aa7e80 --- /dev/null +++ b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/BasePathsTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections; + +import com.trivago.cluecumber.engine.rendering.pages.renderering.BasePaths; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BasePathsTest { + + @Test + void testBasePathsRelativePathsBecomeAbsolute() { + List basePaths = List.of( + Path.of("/features/"), + Path.of("features/foo") // Relative path + ); + BasePaths basePathsObject = new BasePaths(basePaths); + + assertTrue(basePathsObject.getBasePaths().contains(Path.of("/features"))); + assertTrue(basePathsObject.getBasePaths().contains(Path.of("/features/foo"))); + } + + @Test + void testExactMatch() { + BasePaths basePaths = new BasePaths(List.of( + Path.of("/features") + )); + Path inputPath = Path.of("/features"); + + Path result = basePaths.stripBasePath(inputPath); + + assertEquals("/", result.toString()); + } + + @Test + void testRelativeMatch() { + BasePaths basePaths = new BasePaths(List.of( + Path.of("/features") + )); + Path inputPath = Path.of("features/foo"); + + Path result = basePaths.stripBasePath(inputPath); + + assertEquals("/foo", result.toString()); + } + + @Test + void testLongestMatch() { + BasePaths basePaths = new BasePaths(List.of( + Path.of("/features"), + Path.of("/features/foo") + )); + Path inputPath = Path.of("/features/foo/blah"); + + Path result = basePaths.stripBasePath(inputPath); + + assertNotNull(result); + assertEquals("/blah", result.toString()); + } + + @Test + void testNoMatch() { + BasePaths basePaths = new BasePaths(List.of( + Path.of("/unrelated/path") + )); + Path inputPath = Path.of("/features/foo/blah"); + + Path result = basePaths.stripBasePath(inputPath); + + assertNotNull(result); + assertEquals("/features/foo/blah", result.toString()); + } + + @Test + void testRelativeInputPathWithAbsoluteBasePaths() { + BasePaths basePaths = new BasePaths(List.of( + Path.of("/features/"), + Path.of("/features/foo/") + )); + Path inputPath = Path.of("features/foo/blah"); // Relative input + + Path result = basePaths.stripBasePath(inputPath); + + assertNotNull(result); + assertEquals("/blah", result.toString()); + } + + @Test + void testNullInput() { + BasePaths basePaths = new BasePaths(List.of( + Path.of("/features") + )); + + assertThrows(NullPointerException.class, () -> basePaths.stripBasePath(null)); + } + + @Test + void testEmptyBasePaths() { + BasePaths basePaths = new BasePaths(List.of()); + Path inputPath = Path.of("/features/foo/blah"); + + Path result = basePaths.stripBasePath(inputPath); + + assertNotNull(result); + assertEquals("/features/foo/blah", result.toString()); + } +} \ No newline at end of file diff --git a/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/PojoTest.java b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/PojoTest.java index b57c00b0..0d28b7ab 100644 --- a/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/PojoTest.java +++ b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/pojos/pagecollections/PojoTest.java @@ -15,7 +15,7 @@ import java.util.List; public class PojoTest { - private static final int EXPECTED_CLASS_COUNT = 12; + private static final int EXPECTED_CLASS_COUNT = 13; private static final String POJO_PACKAGE = "com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections"; @BeforeAll diff --git a/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatterTest.java b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatterTest.java new file mode 100644 index 00000000..f5ddf0d5 --- /dev/null +++ b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/DirectoryNameFormatterTest.java @@ -0,0 +1,167 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DirectoryNameFormatterTest { + + @Nested + class StandardFormatterTests { + + @Test + void shouldReturnNameAsIs() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.Standard(); + assertEquals("Already Formatted", formatter.toDisplayName("Already Formatted")); + } + + @Test + void shouldHandleEmptyInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.Standard(); + assertEquals("", formatter.toDisplayName("")); + } + + @Test + void shouldHandleNullInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.Standard(); + assertThrows(NullPointerException.class, () -> formatter.toDisplayName(null)); + } + } + + @Nested + class SnakeCaseFormatterTests { + + @Test + void shouldConvertSnakeCaseToTitleCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.SnakeCase(); + assertEquals("This Is A Directory", formatter.toDisplayName("this_is_a_directory")); + } + + @Test + void shouldHandleEmptyInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.SnakeCase(); + assertEquals("", formatter.toDisplayName("")); + } + + @Test + void shouldHandleSingleWord() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.SnakeCase(); + assertEquals("Directory", formatter.toDisplayName("directory")); + } + + @Test + void shouldHandleMixedCaseInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.SnakeCase(); + assertEquals("Directory Loader", formatter.toDisplayName("directory_Loader")); + } + + @Test + void shouldCapitalizeMinorWordsInSnakeCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.SnakeCase(); + assertEquals("An Introduction To Programming With Java", + formatter.toDisplayName("an_introduction_to_programming_with_java")); + } + } + + @Nested + class CamelCaseFormatterTests { + + @Test + void shouldConvertCamelCaseToTitleCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.CamelCase(); + assertEquals("This Is A Directory", formatter.toDisplayName("thisIsADirectory")); + } + + @Test + void shouldHandleSingleWord() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.CamelCase(); + assertEquals("Directory", formatter.toDisplayName("directory")); + } + + @Test + void shouldHandleMixedCaseInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.CamelCase(); + assertEquals("Loader PDF", formatter.toDisplayName("loaderPDF")); + } + + @Test + void shouldCapitalizeMinorWordsInCamelCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.CamelCase(); + assertEquals("An Introduction To Programming With Java", + formatter.toDisplayName("anIntroductionToProgrammingWithJava")); + } + + @Test + void shouldHandleUpperCaseAcronymInCamelCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.CamelCase(); + assertEquals("XML Parser", formatter.toDisplayName("XMLParser")); + } + + @Test + void shouldHandleEmptyInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.CamelCase(); + assertEquals("", formatter.toDisplayName("")); + } + + @Test + void shouldHandleNumbersInCamelCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.CamelCase(); + + assertEquals("99 Bottles", formatter.toDisplayName("99bottles")); + assertEquals("Bottles 99", formatter.toDisplayName("bottles99")); + assertEquals("99 Bottles Of Beer", formatter.toDisplayName("99bottlesOfBeer")); + } + } + + @Nested + class KebabCaseFormatterTests { + + @Test + void shouldConvertKebabCaseToTitleCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.KebabCase(); + assertEquals("This Is A Directory", formatter.toDisplayName("this-is-a-directory")); + } + + @Test + void shouldHandleSingleWord() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.KebabCase(); + assertEquals("Directory", formatter.toDisplayName("directory")); + } + + @Test + void shouldHandleMixedCaseInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.KebabCase(); + assertEquals("Loader Pdf", formatter.toDisplayName("loader-Pdf")); + } + + @Test + void shouldCapitalizeMinorWordsInKebabCase() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.KebabCase(); + assertEquals("An Introduction To Programming With Java", + formatter.toDisplayName("an-introduction-to-programming-with-java")); + } + + @Test + void shouldHandleEmptyInput() { + DirectoryNameFormatter formatter = new DirectoryNameFormatter.KebabCase(); + assertEquals("", formatter.toDisplayName("")); + } + } +} \ No newline at end of file diff --git a/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparatorTest.java b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparatorTest.java new file mode 100644 index 00000000..65528036 --- /dev/null +++ b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathComparatorTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PathComparatorTest { + + private final PathComparator classUnderTest = new PathComparator(); + + @Test + void testSortPathsWithNestedSubsets() { + List paths = List.of( + Path.of("/features/shoppingcart"), + Path.of("/features/checkout/payment"), + Path.of("/features/checkout"), + Path.of("/features/"), + Path.of("/features/checkout/shipping") + ); + + paths = paths.stream() + .sorted(classUnderTest) + .collect(Collectors.toList()); + + List expectedOrder = List.of( + Path.of("/features/"), + Path.of("/features/checkout"), + Path.of("/features/checkout/payment"), + Path.of("/features/checkout/shipping"), + Path.of("/features/shoppingcart") + ); + + assertEquals(expectedOrder, paths); + } +} \ No newline at end of file diff --git a/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtilsTest.java b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtilsTest.java new file mode 100644 index 00000000..2c918b66 --- /dev/null +++ b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/PathUtilsTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 trivago N.V. + * + * 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 + * + * http://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. + */ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PathUtilsTest { + + @ParameterizedTest(name = "Test case {index}: input=\"{0}\"") + @CsvSource({ + "/resources/features/Login.feature, /resources/features", + "classpath:Calendar.feature,/", + "classpath:org/ShoppingCart.feature, org", + "feature/Karate.feature, feature", + "feature/../KarateEmbedded.feature,/", + "features/feature1.feature, features", + "features/../feature2.feature,/", + "file:API/src/test/resources/features/orion_user.feature, API/src/test/resources/features", + "file:API/src/test/resources/features/foo/../orion_user.feature, API/src/test/resources/features", + "file:target/parallel/features/MyTest10_scenario007_run001_IT.feature, target/parallel/features", + "http://example.com/features/test.feature, /features" + }) + void testExtractDirectoryPath(String input, Path expected) { + Path actual = PathUtils.extractDirectoryPath(input); + assertEquals(expected, actual, + "Expected directory path for input \"" + input + "\" does not match."); + } +} \ No newline at end of file diff --git a/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRendererTest.java b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRendererTest.java new file mode 100644 index 00000000..13ffc38f --- /dev/null +++ b/engine/src/test/java/com/trivago/cluecumber/engine/rendering/pages/renderering/TreeViewPageRendererTest.java @@ -0,0 +1,115 @@ +package com.trivago.cluecumber.engine.rendering.pages.renderering; + +import com.trivago.cluecumber.engine.exceptions.CluecumberException; +import com.trivago.cluecumber.engine.json.pojo.Report; +import com.trivago.cluecumber.engine.properties.PropertyManager; +import com.trivago.cluecumber.engine.rendering.pages.pojos.Feature; +import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.AllFeaturesPageCollection; +import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.AllScenariosPageCollection; +import com.trivago.cluecumber.engine.rendering.pages.pojos.pagecollections.TreeViewPageCollection; +import freemarker.template.Template; +import freemarker.template.TemplateException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TreeViewPageRendererTest { + + private TreeViewPageRenderer treeViewPageRenderer; + private PropertyManager propertyManager; + + @BeforeEach + void setup() { + propertyManager = mock(PropertyManager.class); + treeViewPageRenderer = new TreeViewPageRenderer(propertyManager); + } + + @Test + void testContentRenderingWithoutPathTreeView() throws CluecumberException, TemplateException, IOException { + Template template = mock(Template.class); + AllFeaturesPageCollection allFeaturesPageCollection = new AllFeaturesPageCollection( + List.of(aReportWithUri("/features/foo/feature1.feature")), "All Features"); + AllScenariosPageCollection allScenariosPageCollection = new AllScenariosPageCollection(""); + + givenNoPathTreeView(); + + ArgumentCaptor pageCollectionCaptor = ArgumentCaptor.forClass(TreeViewPageCollection.class); + + treeViewPageRenderer.getRenderedContent( + allFeaturesPageCollection, + allScenariosPageCollection, + template + ); + + verify(template, times(1)).process(pageCollectionCaptor.capture(), any()); + + TreeViewPageCollection capturedPageCollection = pageCollectionCaptor.getValue(); + assertNotNull(capturedPageCollection); + assertEquals("All Features", capturedPageCollection.getPageTitle()); + final Path expectedPath = Path.of("/"); + assertEquals(capturedPageCollection.getPaths(), Set.of(expectedPath)); + List featuresAtPath = capturedPageCollection.getFeaturesByPath().get(expectedPath) + .stream().map(TreeViewPageCollection.FeatureAndScenarios::getFeature).collect(Collectors.toList()); + assertTrue(featuresAtPath.containsAll(allFeaturesPageCollection.getFeatures())); + } + + @Test + void testContentRenderingWithPathTreeView() throws CluecumberException, TemplateException, IOException { + Template template = mock(Template.class); + AllFeaturesPageCollection allFeaturesPageCollection = new AllFeaturesPageCollection( + List.of(aReportWithUri("/features/customers/registration/form_validation/email_address.feature")), "All Features"); + AllScenariosPageCollection allScenariosPageCollection = new AllScenariosPageCollection(""); + + when(propertyManager.isGroupFeaturesByPath()).thenReturn(true); + when(propertyManager.getRemovableBasePaths()).thenReturn(BasePaths.fromStrings(List.of("/features"))); + when(propertyManager.getDirectoryNameFormatter()).thenReturn(new DirectoryNameFormatter.SnakeCase()); + + ArgumentCaptor pageCollectionCaptor = ArgumentCaptor.forClass(TreeViewPageCollection.class); + + treeViewPageRenderer.getRenderedContent( + allFeaturesPageCollection, + allScenariosPageCollection, + template + ); + + verify(template, times(1)).process(pageCollectionCaptor.capture(), any()); + + TreeViewPageCollection capturedPageCollection = pageCollectionCaptor.getValue(); + assertNotNull(capturedPageCollection); + assertEquals("All Features", capturedPageCollection.getPageTitle()); + + final Path expectedPath = Path.of("/customers/registration/form_validation"); + assertEquals(capturedPageCollection.getPaths(), Set.of(expectedPath)); + List featuresAtPath = capturedPageCollection.getFeaturesByPath().get(expectedPath) + .stream().map(TreeViewPageCollection.FeatureAndScenarios::getFeature).collect(Collectors.toList()); + assertTrue(featuresAtPath.containsAll(allFeaturesPageCollection.getFeatures())); + } + + private void givenNoPathTreeView() { + when(propertyManager.isGroupFeaturesByPath()).thenReturn(false); + } + + private static Report aReportWithUri(String uri) { + Report report = new Report(); + report.setName("Feature 1"); + report.setDescription("Description"); + report.setFeatureIndex(1); + report.setUri(uri); + return report; + } +} \ No newline at end of file diff --git a/examples/core-example/pom.xml b/examples/core-example/pom.xml index 210eb1df..00cf9b9d 100644 --- a/examples/core-example/pom.xml +++ b/examples/core-example/pom.xml @@ -6,7 +6,7 @@ blog.softwaretester core-example - 3.10.0 + 3.11.0 jar diff --git a/examples/maven-example/pom.xml b/examples/maven-example/pom.xml index c9f19523..3bd35711 100644 --- a/examples/maven-example/pom.xml +++ b/examples/maven-example/pom.xml @@ -6,7 +6,7 @@ blog.softwaretester maven-example - 3.10.0 + 3.11.0 pom @@ -95,6 +95,16 @@ + + false + + feature + features + + + com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter$KebabCase + + diff --git a/maven/src/main/java/com/trivago/cluecumber/maven/CluecumberMaven.java b/maven/src/main/java/com/trivago/cluecumber/maven/CluecumberMaven.java index ade0e57f..9b3612f8 100644 --- a/maven/src/main/java/com/trivago/cluecumber/maven/CluecumberMaven.java +++ b/maven/src/main/java/com/trivago/cluecumber/maven/CluecumberMaven.java @@ -25,6 +25,7 @@ import org.apache.maven.plugins.annotations.Parameter; import java.util.LinkedHashMap; +import java.util.Set; /** * The main plugin class. @@ -95,6 +96,24 @@ public final class CluecumberMaven extends AbstractMojo { @Parameter(property = "reporting.customFavicon") private String customFavicon = ""; + /** + * The base paths to be removed from feature file URIs before grouping. + */ + @Parameter(property = "reporting.removableBasePaths") + private Set removableBasePaths; + + /** + * The fully qualified class name of the directory name formatter implementation (default: com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter$Standard). + */ + @Parameter(property = "reporting.directoryNameFormatter", defaultValue = "com.trivago.cluecumber.engine.rendering.pages.renderering.DirectoryNameFormatter$Standard") + private String directoryNameFormatter; + + /** + * Whether to use a path-based tree view for grouping features (default: false). + */ + @Parameter(property = "reporting.groupFeaturesByPath", defaultValue = "false") + private boolean groupFeaturesByPath; + /** * Custom flag that determines if step output sections of scenario detail pages should be expanded (default: false). */ @@ -203,6 +222,9 @@ public void execute() throws MojoExecutionException { .setCustomParametersFile(customParametersFile) .setCustomStatusColorFailed(customStatusColorFailed) .setCustomStatusColorPassed(customStatusColorPassed) + .setRemovableBasePaths(removableBasePaths) + .setDirectoryNameFormatter(directoryNameFormatter) + .setGroupFeaturesByPath(groupFeaturesByPath) .setExpandSubSections(expandSubSections) .setExpandOutputs(expandOutputs) .setExpandAttachments(expandAttachments) diff --git a/pom.xml b/pom.xml index 60cd6a0d..84d81754 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ - 3.10.0 + 3.11.0 11 11 11