diff --git a/.vscode/launch.json b/.vscode/launch.json index 94799b60..e77f8d67 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,7 @@ "${workspaceFolder}/reporter/plaintext/src", "${workspaceFolder}/reporter/html/src", "${workspaceFolder}/reporter/aspec/src", + "${workspaceFolder}/reporter/ux/src", "${workspaceFolder}/product/src/test/java", "${workspaceFolder}/api/src", "${workspaceFolder}/exporter/specobject/src", diff --git a/README.md b/README.md index 10af627d..3d62aef1 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Below you see a screenshot of an HTML tracing report where OFT traces itself. Yo OFT HTML tracing report +In addition to the HTML tracing report an interactive requirement browser and analysis tool is integrated into OpenFastTrace. + ## Project Information [![Build](https://github.com/itsallcode/openfasttrace/actions/workflows/build.yml/badge.svg)](https://github.com/itsallcode/openfasttrace/actions/workflows/build.yml) diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/ReportSettings.java b/api/src/main/java/org/itsallcode/openfasttrace/api/ReportSettings.java index b8af61b7..494dd19c 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/ReportSettings.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/ReportSettings.java @@ -19,7 +19,12 @@ public class ReportSettings private final ColorScheme colorScheme; private final DetailsSectionDisplay detailsSectionDisplay; - private ReportSettings(final Builder builder) + /** + * Settings for a reporter. + * + * @param builder builder for a reporter + */ + protected ReportSettings(final Builder builder) { this.verbosity = builder.verbosity; this.showOrigin = builder.showOrigin; @@ -121,7 +126,10 @@ public static class Builder private ReportVerbosity verbosity = ReportVerbosity.FAILURE_DETAILS; private ColorScheme colorScheme = ColorScheme.BLACK_AND_WHITE; - private Builder() + /** + * Create the builder + */ + protected Builder() { // empty by intention } diff --git a/core/src/main/resources/usage.txt b/core/src/main/resources/usage.txt index f1616092..90c63c2c 100644 --- a/core/src/main/resources/usage.txt +++ b/core/src/main/resources/usage.txt @@ -8,7 +8,7 @@ Commands: convert Convert to a different requirements format Tracing options: - -o, --output-format Report format, one of "plain", "html", "aspec" + -o, --output-format Report format, one of "plain", "html", "aspec", "ux" Defaults to "plain" -v, --report-verbosity Set how verbose the output is. Ranges from "quiet" to "all". diff --git a/doc/user_guide.md b/doc/user_guide.md index 89afea1f..2635ec79 100644 --- a/doc/user_guide.md +++ b/doc/user_guide.md @@ -462,6 +462,18 @@ While plain text reports are perfect for debugging your tracing chain, sometimes oft trace -o html ``` +### Interactive requirement analyisis + +Besides a basic HTML visualization of requirements OpenFastTrace also provides an interactive requirement browsing and requirement analysis frontend in the form of a responsive HTML page similar to the HTML report. + +The UX reporter: + +``` +oft trace -o ux +``` + +generates an input file for the OpenFastTrace-UX HTML frontend [OpenFastTrace-UX](https://github.com/poldi2015/openfasttrace-ux). + ### Understanding and Fixing Broken Requirement Branches Requirements — or specification items as we call them more broadly — in OFT are internally organized in a graph. If you haven't heard of that term, don't worry. In most cases it is close enough to think of the relationships between the specification items like a forest where the highest level of the specification are tree trunks from which details branch out into big branches, twigs and eventually leaves. @@ -568,6 +580,7 @@ One of: * `plain` * `html` * `aspec` +* `ux` Defaults to `plain`. diff --git a/oft-self-trace.sh b/oft-self-trace.sh index 47b612c0..983d830a 100755 --- a/oft-self-trace.sh +++ b/oft-self-trace.sh @@ -27,6 +27,7 @@ if $oft_script trace \ "$base_dir/reporter/plaintext/src" \ "$base_dir/reporter/html/src" \ "$base_dir/reporter/aspec/src" \ + "$base_dir/reporter/ux/src" \ "$base_dir/product/src/test/java" \ "$base_dir/api/src" \ "$base_dir/exporter/specobject/src" \ diff --git a/parent/pom.xml b/parent/pom.xml index f1b712d0..51ff11e3 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -10,7 +10,7 @@ Free requirement tracking suite https://github.com/itsallcode/openfasttrace - 4.1.0 + 4.2.0 17 5.11.4 3.5.2 @@ -186,6 +186,12 @@ ${revision} compile + + org.itsallcode.openfasttrace + openfasttrace-reporter-ux + ${revision} + compile + org.itsallcode.openfasttrace openfasttrace-testutil @@ -378,7 +384,7 @@ true true - true + false false -html5 diff --git a/pom.xml b/pom.xml index 1d9da986..d7c43adc 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ reporter/plaintext reporter/html reporter/aspec + reporter/ux testutil diff --git a/product/pom.xml b/product/pom.xml index ced14315..6cc121cf 100644 --- a/product/pom.xml +++ b/product/pom.xml @@ -57,6 +57,10 @@ org.itsallcode.openfasttrace openfasttrace-reporter-aspec + + org.itsallcode.openfasttrace + openfasttrace-reporter-ux + org.itsallcode.openfasttrace openfasttrace-testutil diff --git a/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java b/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java index 33368f05..c238dc79 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java @@ -100,7 +100,7 @@ void exporterAvailable(final String format) @ParameterizedTest @CsvSource( - { "aspec", "html", "plain" }) + { "aspec", "html", "plain", "ux" }) void reporterAvailable(final String format) { if (!reporterLoader.isFormatSupported(format)) diff --git a/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java b/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java index 63f89839..8f91bffc 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java @@ -21,6 +21,7 @@ import org.itsallcode.openfasttrace.report.aspec.ASpecReporterFactory; import org.itsallcode.openfasttrace.report.html.HtmlReporterFactory; import org.itsallcode.openfasttrace.report.plaintext.PlaintextReporterFactory; +import org.itsallcode.openfasttrace.report.ux.UxReporterFactory; import org.junit.jupiter.api.Test; /** @@ -83,9 +84,10 @@ void testReporterFactoriesRegistered() final ReporterContext context = new ReporterContext(null); final List services = getRegisteredServices(ReporterFactory.class, context); - assertThat(services, hasSize(3)); + assertThat(services, hasSize(4)); assertThat(services, containsInAnyOrder(instanceOf(PlaintextReporterFactory.class), instanceOf(ASpecReporterFactory.class), + instanceOf(UxReporterFactory.class), instanceOf(HtmlReporterFactory.class))); for (final ReporterFactory factory : services) { diff --git a/reporter/ux/pom.xml b/reporter/ux/pom.xml new file mode 100644 index 00000000..ae01cf9f --- /dev/null +++ b/reporter/ux/pom.xml @@ -0,0 +1,32 @@ + + 4.0.0 + openfasttrace-reporter-ux + OpenFastTrace UX Reporter + + ../../parent/pom.xml + org.itsallcode.openfasttrace + openfasttrace-parent + ${revision} + + + ${reproducible.build.timestamp} + + + + org.itsallcode.openfasttrace + openfasttrace-api + + + org.itsallcode.openfasttrace + openfasttrace-testutil + test + + + org.itsallcode.openfasttrace + openfasttrace-core + test + + + \ No newline at end of file diff --git a/reporter/ux/src/main/java/module-info.java b/reporter/ux/src/main/java/module-info.java new file mode 100644 index 00000000..319cf608 --- /dev/null +++ b/reporter/ux/src/main/java/module-info.java @@ -0,0 +1,13 @@ +/** + * This provides an interactive HTML requirement browser. + * + * @provides org.itsallcode.openfasttrace.api.report.ReporterFactory + */ +module org.itsallcode.openfasttrace.report.ux +{ + requires transitive org.itsallcode.openfasttrace.api; + requires java.logging; + + provides org.itsallcode.openfasttrace.api.report.ReporterFactory + with org.itsallcode.openfasttrace.report.ux.UxReporterFactory; +} diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/Collector.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/Collector.java new file mode 100644 index 00000000..feaf9e03 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/Collector.java @@ -0,0 +1,564 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.itsallcode.openfasttrace.api.core.*; +import org.itsallcode.openfasttrace.report.ux.model.Coverage; +import org.itsallcode.openfasttrace.report.ux.model.UxModel; +import org.itsallcode.openfasttrace.report.ux.model.UxSpecItem; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Collector traverses a {@link LinkedSpecificationItem} tree and provides a {@link UxSpecItem} and + * a {@link UxModel} based on the parsed items. + */ +public class Collector { + + private final List items = new ArrayList<>(); + private final List ids = new ArrayList<>(); + + private final List allTypes = new ArrayList<>(); + private final List orderedTypes = new ArrayList<>(); + + private final List tags = new ArrayList<>(); + private final List tagCount = new ArrayList<>(); + + final List> itemCoverages = new ArrayList<>(); + + private final List isDeepCovered = new ArrayList<>(); + + private final List uxItems = new ArrayList<>(); + + private final List typeCount = new ArrayList<>(); + + private final List uncoveredCounts = new ArrayList<>(); + + private final List statusCount = new ArrayList<>(); + + private UxModel uxModel = null; + + public Collector() { + } + + /** + * Fill in the caches of the Collector based on the given items. + * + * @param specItems + * {@link LinkedSpecificationItem} model. + */ + public Collector collect(List specItems) { + this.items.clear(); + this.items.addAll(specItems); + + initializeIndexes(); + collectItemCoverages(); + collectUxItems(); + collectUxModel(); + + return this; + } + + /** + * @return unordered list of {@link SpecificationItem} types. + */ + public List getAllTypes() { + return allTypes; + } + + /** + * @return {@link SpecificationItem} types ordered base on the downward linkage of items. + */ + public List getOrderedTypes() { + return orderedTypes; + } + + /** + * @return all tags of all items. + */ + public List getTags() { + return tags; + } + + /** + * ItemCoverages provide a shallow coverages for each type of {@link SpecificationItem} type based on the linkage + * of a SpecItem. + * The linkage tree is flattened, means the shallows coverages of all types merged. Merged means that the type is + * not part of the tree {@link Coverage#NONE} is returned, {@link Coverage#UNCOVERED} is returned when at least one + * item of the type is uncovered. {@link Coverage#COVERED} is returned when all items of a type are covered. + * + * @return list of coverages indexes by {@link LinkedSpecificationItem} handed in to {@link #collect(List)}. + */ + public List> getItemCoverages() { + return itemCoverages; + } + + /** + * @return the metamodel of the collected items. + */ + public UxModel getUxModel() { + return uxModel; + } + + /** + * @return All {@link UxSpecItem} matching all items given to {@link #collect(List)} + */ + public List getUxItems() { + return uxItems; + } + + // + // private members + + // UxModel + + private void collectUxModel() { + uxModel = UxModel.Builder.builder() + .withProjectName(generateProjectName("")) + .withArtifactTypes(orderedTypes) + .withNumberOfSpecItems(items.size()) + .withUncoveredSpecItems(items.size() - (int) isDeepCovered.stream().filter(covered -> covered).count()) + .withTags(tags) + .withStatusNames(Arrays.stream(ItemStatus.values()).map(ItemStatus::toString).toList()) + .withTypeCount(typeCount) + .withUncoveredCount(uncoveredCounts) + .withStatusCount(statusCount) + .withTagCount(tagCount) + .withItems(uxItems) + .build(); + } + + private String generateProjectName(final String name) { + final StringBuilder projectName = new StringBuilder(); + projectName.append(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME).replaceAll(":", ".")); + if( name != null && !name.isEmpty() ) projectName.append(name).append("-"); + + return projectName.toString(); + } + + private void collectUxItems() { + uxItems.clear(); + for( int i = 0; i < items.size(); i++ ) { + uxItems.add(createUxSpecItem(i)); + } + } + + UxSpecItem createUxSpecItem(final int index) { + final LinkedSpecificationItem item = items.get(index); + return UxSpecItem.Builder.builder() + .withIndex(index) + .withTypeIndex(orderedTypes.indexOf(item.getArtifactType())) + .withTitle(toTitle(item)) + .withName(toName(item)) + .withId(toId(item)) + .withTagIndex(toTagIndex(item)) + .withProvidesIndex(getProvidesTypeIndex(item)) + .withNeededTypeIndex(typeToIndex(item.getNeedsArtifactTypes())) + .withCoveredIndex(toCoveragesIds(index)) + .withUncoveredIndex(toUncoveredIndexes(index)) + .withCoveringIndex(toItemIndex(item.getLinksByStatus(LinkStatus.COVERS))) + .withCoveredByIndex(toItemIndex(item.getLinksByStatus(LinkStatus.COVERED_SHALLOW))) + .withDependsIndex(toIdIndex(item.getItem().getDependOnIds())) + .withStatusId(item.getItem().getStatus().ordinal()) + //.withPath() + .withItem(item) + .build(); + } + + private List typeToIndex(final List types) { + return types.stream().map(orderedTypes::indexOf).toList(); + } + + private String toTitle(final LinkedSpecificationItem item) { + return item.getTitleWithFallback(); + } + + private String toName(final LinkedSpecificationItem item) { + return item.getId().getName(); + } + + private String toId(final LinkedSpecificationItem item) { + final String type = item.getId().getArtifactType(); + final String name = item.getId().getName(); + final int version = item.getId().getRevision(); + return version > 1 ? type + ":" + name + ":" + version : type + ":" + name; + } + + private List getProvidesTypeIndex( final LinkedSpecificationItem item ) { + List uplinks = item.getLinks().getOrDefault(LinkStatus.COVERS,List.of()); + return typeToIndex(uplinks.stream().map(LinkedSpecificationItem::getArtifactType).toList()); + } + + private List toCoveragesIds(final int index) { + final Map coverages = itemCoverages.get(index); + return orderedTypes.stream().map(type -> { + final Coverage coverage = coverages.get(type); + return coverage != null ? coverage.getId() : Coverage.NONE.getId(); + }).toList(); + } + + private List toUncoveredIndexes(final int index) { + final Map coverages = itemCoverages.get(index); + final List uncoveredIndexes = new ArrayList<>(); + int i = 0; + for( final String type : orderedTypes ) { + final Coverage coverage = coverages.get(type); + if( ( coverage == Coverage.UNCOVERED || coverage == Coverage.MISSING ) ) { + uncoveredIndexes.add(i); + } + i++; + } + return uncoveredIndexes; + } + + private List toItemIndex(final List items) { + return items.stream().map(item -> ids.indexOf(item.getId())).toList(); + } + + private List toIdIndex(final List ids) { + return ids.stream().map(this.ids::indexOf).filter(id -> id >= 0).toList(); + } + + private List toTagIndex(final LinkedSpecificationItem item) { + return item.getTags().stream().map(tags::indexOf).toList(); + } + + // Types and indexes + + /** + * Fill alTypes and orderedTypes. + */ + private void initializeIndexes() { + allTypes.clear(); + allTypes.addAll(collectAllTypes(items)); + orderedTypes.clear(); + orderedTypes.addAll(createOrderedTypes(items)); + typeCount.clear(); + typeCount.addAll(collectTypeCount(items, orderedTypes)); + + ids.clear(); + ids.addAll(items.stream().map(LinkedSpecificationItem::getId).toList()); + + tags.clear(); + tagCount.clear(); + final Map tagMap = collectTagCount(items); + final List tagList = new ArrayList<>(tagMap.keySet()); + tags.addAll(tagList); + tagList.forEach(tag -> tagCount.add(tagMap.get(tag))); + + statusCount.clear(); + statusCount.addAll(collectStatusCount(items)); + } + + /** + * @return Get all types of all specItems. + */ + static Set collectAllTypes(final List items) { + return items.stream().map(LinkedSpecificationItem::getArtifactType).collect(Collectors.toSet()); + } + + /** + * Provides a list of tags accompanied by the number of items that provides a specific tags. + * + * @param items + * The items to process + * @return tag to count mapping + */ + static Map collectTagCount(final List items) + { + final Map tags = new HashMap<>(); + for (final LinkedSpecificationItem item : items) + { + for (final String tag : item.getTags()) + { + tags.put(tag, tags.getOrDefault(tag, 0) + 1); + } + } + + return tags; + } + + /** + * Provide a list of artifact types sorted by needs dependencies extracted form items. + * + * @param items + * Items to process + * @return order types + */ + static List createOrderedTypes(final List items) { + final List orderedTypes = new ArrayList<>(); + final Map dependenciesByType = collectDependentTypes(items); + + // Kahn's BFS algorithm + while( !dependenciesByType.isEmpty() ) { + final Map previousDependenciesByType = new HashMap<>(dependenciesByType); + + for( final Map.Entry neededTypeEntry : previousDependenciesByType.entrySet() ) { + final String type = neededTypeEntry.getKey(); + final TypeDependencies dependencies = neededTypeEntry.getValue(); + if( dependencies.needs.isEmpty() ) { + orderedTypes.add(0, type); + dependencies.provides.forEach( + (providerType) -> dependenciesByType.get(providerType).needs.remove(type)); + dependenciesByType.remove(type); + } + } + + // Break circles + if( dependenciesByType.size() == previousDependenciesByType.size() ) { + orderedTypes.addAll(0, dependenciesByType.keySet()); + dependenciesByType.clear(); + } + } + + return orderedTypes; + } + + static class TypeDependencies { + public final Set provides = new HashSet<>(); + public final Set needs = new HashSet<>(); + + @Override public String toString() { + return String.format("{provides{%s}, needs[%s]}", String.join(",", provides), String.join(",", needs)); + } + } // TypeDependencies + + /** + * @return superset of all types needed by a type for all types of all items + */ + static Map collectDependentTypes(final List items) { + final Map dependenciesByType = new HashMap<>(); + for( final LinkedSpecificationItem item : items ) { + final String itemType = item.getArtifactType(); + final TypeDependencies dependencies = dependenciesByType.getOrDefault(itemType, new TypeDependencies()); + + // Add needed to processed item + dependencies.needs.addAll(item.getNeedsArtifactTypes()); + dependenciesByType.put(itemType, dependencies); + + // Add item type to provides of all needed types + for( final String need : dependencies.needs ) { + final TypeDependencies providerDependencies = dependenciesByType.getOrDefault(need, + new TypeDependencies()); + providerDependencies.provides.add(itemType); + dependenciesByType.put(need, providerDependencies); + } + } + return dependenciesByType; + } + + /** + * Collects the number of items for all types of the given types. + * + * @param items + * The items to process + * @param orderedTypes + * The index of the returned list is the index of the type in orderedTypes + */ + private static List collectTypeCount(final List items, + final List orderedTypes) + { + final List typeCount = new ArrayList<>(Collections.nCopies(orderedTypes.size(), 0)); + for (final LinkedSpecificationItem item : items) + { + final int typeIndex = orderedTypes.indexOf(item.getArtifactType()); + typeCount.set(typeIndex, typeCount.get(typeIndex) + 1); + } + + return typeCount; + } + + private static List collectStatusCount(final List items) + { + final List statusCount = new ArrayList<>(Collections.nCopies(ItemStatus.values().length, 0)); + for (final LinkedSpecificationItem item : items) + { + final int statusIndex = item.getStatus().ordinal(); + statusCount.set(statusIndex, statusIndex < statusCount.size() ? statusCount.get(statusIndex) + 1 : 1); + } + + return statusCount; + } + + + // Covered Status + + /** + * Fill in the ItemCoverages. + */ + void collectItemCoverages() { + // Initialize coverages + itemCoverages.clear(); + for( int i = 0; i < items.size(); i++ ) { + itemCoverages.add(null); + } + + // Initialize uncoveredCounts + uncoveredCounts.clear(); + for (int i = 0; i < orderedTypes.size(); i++) + { + uncoveredCounts.add(0); + } + + // Fill coverages + for( int i = 0; i < items.size(); i++ ) { + final Map itemCoverage = collectItemCoverage(i); + isDeepCovered.add(collectIsCovered(itemCoverage)); + updateUncoveredCount(i,items.get(i).isCoveredShallowWithApprovedItems()); + } + } + + /** + * Update {@link #uncoveredCounts} by incrementing the corresponding entry if the item with the given index is + * uncovered. + * + * @param index + * The index of the processed item + * @param isCovered + * true of the item is covered + * @return true of the item is covered + */ + private boolean updateUncoveredCount(final int index, final boolean isCovered) + { + if (!isCovered) + { + final int uncoveredIndex = orderedTypes.indexOf(items.get(index).getArtifactType()); + uncoveredCounts.set(uncoveredIndex, uncoveredCounts.get(uncoveredIndex) + 1); + } + + return isCovered; + } + + /** + * @param itemCoverage + * collected coverages for an item + * @return true if item is fully covered + */ + private boolean collectIsCovered(final Map itemCoverage) { + return itemCoverage.values().stream().noneMatch(coverage -> coverage == Coverage.UNCOVERED); + } + + /** + * Calculate the coverages for a given {@link LinkedSpecificationItem}. + * The method traverses the tree recursively merging the coverage of all items with the same type + * with {@link #mergeCoverages(Map, Map)} + * + * @param index + * The index within the {@link LinkedSpecificationItem} list. + * @return coverages of the item + */ + Map collectItemCoverage(final int index) { + // Coverage already collected + final Map targetCoverage = itemCoverages.get(index); + if( targetCoverage != null ) { + //System.out.println("<<< already covered index " + index); + return targetCoverage; + } + + final Map coverages = initializedCoverages(orderedTypes); + + // End of the tree + final LinkedSpecificationItem item = items.get(index); + if( item.getNeedsArtifactTypes().isEmpty() ) { + //System.out.println("<<< final " + item.getId()); + return updateItemCoverage(index, + item.getArtifactType(), + item.getStatus() == ItemStatus.APPROVED ? Coverage.COVERED : Coverage.UNCOVERED, + coverages); + } + + // Traverse down + for( final LinkedSpecificationItem coveringItem : item.getLinksByStatus(LinkStatus.COVERED_SHALLOW) ) { + final int coveringIndex = ids.indexOf(coveringItem.getId()); + //System.out.println(">>> coveringItem (" + coveringIndex + ")" + coveringItem.getId()); + final Map collectedCoverages = collectItemCoverage(coveringIndex); + mergeCoverages(collectedCoverages, coverages); + } + + // Refresh this coverage + updateItemCoverage(index, + item.getArtifactType(), + item.isCoveredShallowWithApprovedItems() ? Coverage.COVERED : Coverage.UNCOVERED, + coverages); + + // Refresh needed uncovered types + for( final String uncoveredType : item.getUncoveredApprovedArtifactTypes() ) { + updateItemCoverage(index, uncoveredType, Coverage.MISSING, coverages); + } + + //System.out.println("<<< intermediate " + item.getId()); + return coverages; + } + + /** + * Updates the given coverages by setting the coverage of the given type and updates the {@link #itemCoverages}. + * + * @param index + * The index of the item + * @param artifactType + * The type of the coverage + * @param coverage + * true if the type is covered + * @param coverages + * the coverages to update + * @return the coverages + */ + Map updateItemCoverage(final int index, + final String artifactType, + final Coverage coverage, + final Map coverages) { + coverages.put(artifactType, coverage); + itemCoverages.set(index, coverages); + return coverages; + } + + /** + * Merges to SpecItemType coverages resulting in a superset with Coverage types merged by mergeCoverType. + * + * @param fromCoverages + * types to be merged into toCoverage, may be null + * @param toCoverages + * the target types + * @return true = merged + */ + static boolean mergeCoverages(final Map fromCoverages, + final Map toCoverages) { + if( fromCoverages == null ) return false; + for( final Map.Entry fromCoverage : fromCoverages.entrySet() ) { + final Coverage fromCoverageValue = fromCoverage.getValue(); + final Coverage toCoverageVales = toCoverages.get(fromCoverage.getKey()); + toCoverages.put(fromCoverage.getKey(), mergeCoverType(fromCoverageValue, toCoverageVales)); + } + return true; + } + + /** + * Merges two coverage types. + * At least one coverage type is uncovered, result is uncovered, no type on with returns NONE, both covered + * returns covered. + * + * @param type1 + * First input coverage + * @param type2 + * Second input coverage + * @return merge input coverage + */ + static Coverage mergeCoverType(Coverage type1, Coverage type2) { + return type1 == Coverage.MISSING || type2 == Coverage.MISSING ? Coverage.MISSING + : type1 == Coverage.UNCOVERED || type2 == Coverage.UNCOVERED ? Coverage.UNCOVERED + : type1 == Coverage.COVERED || type2 == Coverage.COVERED ? Coverage.COVERED + : Coverage.NONE; + } + + /** + * @param allTypes + * all known SpecItem types + * @return Map with all SpecItemTypes as name and Coverage.NONE + */ + static Map initializedCoverages(final List allTypes) { + return allTypes.stream().collect( + Collectors.toMap(type -> type, (any) -> Coverage.NONE)); + } + +} // Collector diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/UxReporter.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/UxReporter.java new file mode 100644 index 00000000..4a4f9a16 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/UxReporter.java @@ -0,0 +1,72 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.itsallcode.openfasttrace.api.ReportSettings; +import org.itsallcode.openfasttrace.api.core.Trace; +import org.itsallcode.openfasttrace.api.report.Reportable; +import org.itsallcode.openfasttrace.api.report.ReporterContext; +import org.itsallcode.openfasttrace.report.ux.generator.IGenerator; +import org.itsallcode.openfasttrace.report.ux.generator.JsGenerator; +import org.itsallcode.openfasttrace.report.ux.model.UxModel; + +import java.io.OutputStream; +import java.util.logging.Logger; + +/** + * + */ +public class UxReporter implements Reportable +{ + + private static final Logger LOG = Logger.getLogger(UxReporter.class.getName()); + + private final Trace trace; + private final ReportSettings settings; + + /** + * + * @param trace the traced data + * @param context settings + */ + public UxReporter(final Trace trace, final ReporterContext context) + { + LOG.info(String.format("constructor(context=%s",context.toString())); + this.trace = trace; + this.settings = context.getSettings(); + } + + /** + * Generate output + * @param outputStream The file to write output to + */ + @Override public void renderToStream(OutputStream outputStream) + { + LOG.info("renderToStream"); + final Collector collector = new Collector().collect(trace.getItems()); + final IGenerator generator = new JsGenerator(); + generator.generate(outputStream, extendModel(collector.getUxModel())); + } + + /** + * Adjusts + * + * @param uxModel The collected model + * @return uxModel extended via setting + */ + private static UxModel extendModel(final UxModel uxModel) + { + final UxModel.Builder uxModelBuilder = UxModel.builder(uxModel); + + // Add project name prefix if set + String projectNameEnvironment = System.getenv("oftProjectName"); + if( "".equals(projectNameEnvironment)) projectNameEnvironment = null; + final String projectNameProperty = System.getProperty("oftProjectName"); + if ( projectNameEnvironment != null || projectNameProperty != null) + { + final String projectName = projectNameEnvironment != null ? projectNameEnvironment : projectNameProperty; + uxModelBuilder.withProjectName(projectName + " (" + uxModel.getProjectName() + ")"); + } + + return uxModelBuilder.build(); + } + +} // UxReporter diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/UxReporterFactory.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/UxReporterFactory.java new file mode 100644 index 00000000..0a85f187 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/UxReporterFactory.java @@ -0,0 +1,38 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.itsallcode.openfasttrace.api.core.Trace; +import org.itsallcode.openfasttrace.api.report.Reportable; +import org.itsallcode.openfasttrace.api.report.ReporterFactory; + +import java.util.logging.Logger; + +/** + * Creates the UX exporter + */ +public class UxReporterFactory extends ReporterFactory { + + private static final String UX_REPORT_FORMAT = "ux"; + + public UxReporterFactory() { + } + + /** + * + * @param format to check + * @return if equal to 'ux' + */ + @Override public boolean supportsFormat(String format) + { + return UX_REPORT_FORMAT.equalsIgnoreCase(format); + } + + /** + * Creates the exporter. + * @param trace the traces to process + * @return the report + */ + @Override public Reportable createImporter(Trace trace) + { + return new UxReporter(trace,this.getContext()); + } +} diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/generator/IGenerator.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/generator/IGenerator.java new file mode 100644 index 00000000..c5adaef3 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/generator/IGenerator.java @@ -0,0 +1,13 @@ +package org.itsallcode.openfasttrace.report.ux.generator; + +import org.itsallcode.openfasttrace.report.ux.model.UxModel; + +import java.io.OutputStream; + +public interface IGenerator { + + String type(); + + public void generate(final OutputStream out, final UxModel model); + +} // IGenerator diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/generator/JsGenerator.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/generator/JsGenerator.java new file mode 100644 index 00000000..2a5a354c --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/generator/JsGenerator.java @@ -0,0 +1,170 @@ +package org.itsallcode.openfasttrace.report.ux.generator; + +import org.itsallcode.openfasttrace.api.core.Location; +import org.itsallcode.openfasttrace.report.ux.model.UxModel; +import org.itsallcode.openfasttrace.report.ux.model.UxSpecItem; + +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +public class JsGenerator implements IGenerator { + + public static final String TYPE = "js"; + + /** + * Indentation spaces + */ + private static final int INDENT = 2; + private static final int LINE_LENGTH = 120; + + private int indent = 0; + private PrintStream out = null; + + public JsGenerator() { + } + + @Override public String type() { + return TYPE; + } + + @Override public void generate(final OutputStream outputStream, final UxModel model) { + out = new PrintStream(outputStream, false, StandardCharsets.UTF_8); + generateHeader(model); + generateMetaData(model); + generateSpecItemsOpen(model); + model.getItems().forEach(this::generateSpecItem); + generateSpecItemsClose(model); + generateFooter(model); + } + + private void generateHeader(final UxModel model) { + printOpen("(function (window,undefined) {"); + printOpen("window.specitem = {"); + } + + private void generateMetaData(final UxModel model) { + printOpen("project: {"); + println("projectName", model.getProjectName()); + println("types", model.getArtifactTypes()); + println("tags", model.getTags()); + println("status",model.getStatusNames()); + println("item_count", model.getItems().size()); + println("item_covered", model.getItems().size() - model.getUncoveredSpecItems()); + println("item_uncovered", model.getUncoveredSpecItems()); + println("type_count",model.getTypeCount()); + println("uncovered_count", model.getUncoveredCount()); + println("status_count",model.getStatusCount()); + println("tag_count", model.getTagCount()); + printClose("},"); + } + + private void generateSpecItemsOpen(final UxModel model) { + printOpen("specitems: ["); + } + + private void generateSpecItem(final UxSpecItem item) { + printOpen("{"); + println("index", item.getIndex()); + println("type", item.getTypeIndex()); + println("title", item.getTitle()); + println("name", item.getName()); + println("id", item.getId()); + println("tags", item.getTagIndex()); + println("version", item.getItem().getRevision()); + println("content", item.getItem().getItem().getDescription()); + println("provides", item.getProvidesIndex()); + println("needs", item.getNeededTypeIndex()); + println("covered", item.getCoveredIndex()); + println("uncovered", item.getUncoveredIndex()); + println("covering", item.getCoveringIndex()); + println("coveredBy", item.getCoveredByIndex()); + println("depends", item.getDependsIndex()); + println("status", item.getStatusId()); + println("path", item.getPath()); + final Location location = item.getItem().getItem().getLocation(); + println("sourceFile", location != null ? location.getPath() : ""); + println("sourceLine", location != null ? location.getLine() : 0); + println("comments", item.getItem().getItem().getComment()); + printClose("},"); + } + + private void generateSpecItemsClose(final UxModel model) { + printClose("]"); + } + + private void generateFooter(final UxModel model) { + printClose("}"); + printClose("})(window);"); + } + + private void printf(final String format, Object... args) { + out.print(" ".repeat(indent)); + out.printf(format, args); + out.println(); + } + + private void println(final String name, final int value) { + printf("%s: %d,", name, value); + } + + private void println(final String name, final String value) { + printf("%s:%s", name, wrap(value, name.length())); + } + + private void println(final String name, final List values) { + printf("%s: [%s],", + name, + values.stream().map(value -> + ( value instanceof String ) ? "\"" + value + "\"" : value.toString() + ).collect(Collectors.joining(", "))); + } + + private void printOpen(String text) { + out.println(" ".repeat(indent) + text); + indentBegin(); + } + + private void printClose(String text) { + indentEnd(); + out.println(" ".repeat(indent) + text); + } + + private void indentBegin() { + indent += INDENT; + } + + private void indentEnd() { + indent -= INDENT; + } + + private String wrap(final String text, final int offset) { + final String value = quote(text); + if( value.length() < ( LINE_LENGTH - offset - INDENT - 2 ) ) return " '" + value + "',"; + + final StringBuilder b = new StringBuilder(); + b.append(System.lineSeparator()); + + indentBegin(); + final int fragmentLength = LINE_LENGTH - offset - INDENT - 3; + for( int i = 0; i < value.length(); i += fragmentLength ) { + b.append(" ".repeat(indent)); + b.append(i == 0 ? "'" : "+ '"); + b.append(value, i, Math.min(i + fragmentLength, value.length())); + b.append(( i + fragmentLength ) < value.length() ? "'" + System.lineSeparator() : "',"); + } + indentEnd(); + + return b.toString(); + } + + private String quote(final String text) { + return text.replace("'", "\\\'") + .replace("<", "<") + .replace(">", ">") + .replaceAll("\n\r?|\r", "
"); + } + +} // JsGenerator \ No newline at end of file diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/Coverage.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/Coverage.java new file mode 100644 index 00000000..48670972 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/Coverage.java @@ -0,0 +1,20 @@ +package org.itsallcode.openfasttrace.report.ux.model; + +public enum Coverage +{ + MISSING(3), + COVERED(2), + UNCOVERED(1), + NONE(0); + + Coverage(int id) { + this.id = id; + } + + private final int id; + + public int getId() + { + return id; + } +} diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxModel.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxModel.java new file mode 100644 index 00000000..cf2eb369 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxModel.java @@ -0,0 +1,327 @@ +package org.itsallcode.openfasttrace.report.ux.model; + +import org.itsallcode.openfasttrace.api.core.ItemStatus; +import org.itsallcode.openfasttrace.api.core.SpecificationItem; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * Surrounding model that is used to generate the specitem_data model for OpenFastTrace-UX. + */ +public class UxModel +{ + private final String projectName; + private final List artifactTypes; + private final List tags; + private final List statusNames; + + private final int numberOfSpecItems; + private final int uncoveredSpecItems; + + private final List items; + + private final List typeCount; + private final List uncoveredCount; + private final List statusCount; + private final List tagCount; + + private UxModel(Builder builder) + { + projectName = builder.projectName; + artifactTypes = builder.artifactTypes; + tags = builder.tags; + statusNames = builder.statusNames; + numberOfSpecItems = builder.numberOfSpecItems; + uncoveredSpecItems = builder.uncoveredSpecItems; + items = builder.items; + typeCount = builder.typeCount; + uncoveredCount = builder.uncoveredCount; + statusCount = builder.statusCount; + tagCount = builder.tagCount; + } + + public static Builder builder(UxModel copy) + { + Builder builder = new Builder(); + builder.projectName = copy.getProjectName(); + builder.artifactTypes = copy.getArtifactTypes(); + builder.tags = copy.getTags(); + builder.statusNames = copy.getStatusNames(); + builder.numberOfSpecItems = copy.getNumberOfSpecItems(); + builder.uncoveredSpecItems = copy.getUncoveredSpecItems(); + builder.items = copy.getItems(); + builder.typeCount = copy.getTypeCount(); + builder.uncoveredCount = copy.getUncoveredCount(); + builder.statusCount = copy.getStatusCount(); + builder.tagCount = copy.getTagCount(); + return builder; + } + + /** + * @return Name of the project + */ + public String getProjectName() + { + return projectName; + } + + /** + * @return types of {@link SpecificationItem}s trace + */ + public List getArtifactTypes() + { + return artifactTypes; + } + + /** + * @return Total number of {@link SpecificationItem}s traced + */ + public int getNumberOfSpecItems() + { + return numberOfSpecItems; + } + + /** + * @return Number of traced {@link SpecificationItem}s that have deep uncoverered or a staled coverage. + */ + public int getUncoveredSpecItems() + { + return uncoveredSpecItems; + } + + /** + * @return all tags of all items in index order used by {@link UxSpecItem}. + */ + public List getTags() { + return tags; + } + + /** + * @return The names of the {@link ItemStatus} enum entries. + */ + public List getStatusNames() { + return statusNames; + } + + /** + * @return items within the model + */ + public List getItems() { + return items; + } + + /** + * @return number of items by type index + */ + public List getTypeCount() + { + return typeCount; + } + + /** + * @return covered count per soecObject type + */ + public List getUncoveredCount() + { + return uncoveredCount; + } + + /** + * @return number of items by status index + */ + public List getStatusCount() + { + return statusCount; + } + + /** + * @return number of items by status index + */ + public List getTagCount() + { + return tagCount; + } + + /** + * {@code UxModel} builder static inner class. + */ + public static final class Builder + { + private List artifactTypes; + private List tags; + private List statusNames; + private int numberOfSpecItems; + private int uncoveredSpecItems; + private List items; + private List typeCount; + private List uncoveredCount; + private List statusCount; + private List tagCount; + private String projectName; + + private Builder() + { + } + + public static Builder builder() + { + return new Builder(); + } + + /** + * Sets the {@code artifactTypes} and returns a reference to this Builder enabling method chaining. + * + * @param artifactTypes + * the {@code artifactTypes} to set + * @return a reference to this Builder + */ + public Builder withArtifactTypes(List artifactTypes) + { + this.artifactTypes = artifactTypes; + return this; + } + + /** + * Sets the {@code tags} and returns a reference to this Builder enabling method chaining. + * + * @param tags + * the {@code tags} to set + * @return a reference to this Builder + */ + public Builder withTags(List tags) + { + this.tags = tags; + return this; + } + + /** + * Sets the {@code statusNames} and returns a reference to this Builder enabling method chaining. + * + * @param statusNames + * the {@code statusNames} to set + * @return a reference to this Builder + */ + public Builder withStatusNames(List statusNames) + { + this.statusNames = statusNames; + return this; + } + + /** + * Sets the {@code numberOfSpecItems} and returns a reference to this Builder enabling method chaining. + * + * @param numberOfSpecItems + * the {@code numberOfSpecItems} to set + * @return a reference to this Builder + */ + public Builder withNumberOfSpecItems(int numberOfSpecItems) + { + this.numberOfSpecItems = numberOfSpecItems; + return this; + } + + /** + * Sets the {@code uncoveredSpecItems} and returns a reference to this Builder enabling method chaining. + * + * @param uncoveredSpecItems + * the {@code uncoveredSpecItems} to set + * @return a reference to this Builder + */ + public Builder withUncoveredSpecItems(int uncoveredSpecItems) + { + this.uncoveredSpecItems = uncoveredSpecItems; + return this; + } + + /** + * Sets the {@code items} and returns a reference to this Builder enabling method chaining. + * + * @param items + * the {@code items} to set + * @return a reference to this Builder + */ + public Builder withItems(List items) + { + this.items = items; + return this; + } + + /** + * Sets the {@code typeCount} and returns a reference to this Builder enabling method chaining. + * + * @param typeCount + * the {@code typeCount} to set + * @return a reference to this Builder + */ + public Builder withTypeCount(List typeCount) + { + this.typeCount = typeCount; + return this; + } + + /** + * Sets the {@code uncoveredCount} and returns a reference to this Builder enabling method chaining. + * + * @param uncoveredCount + * the {@code uncoveredCount} to set + * @return a reference to this Builder + */ + public Builder withUncoveredCount(List uncoveredCount) + { + this.uncoveredCount = uncoveredCount; + return this; + } + + /** + * Sets the {@code statusCount} and returns a reference to this Builder enabling method chaining. + * + * @param statusCount + * the {@code statusCount} to set + * @return a reference to this Builder + */ + public Builder withStatusCount(List statusCount) + { + this.statusCount = statusCount; + return this; + } + + /** + * Sets the {@code tagCount} and returns a reference to this Builder enabling method chaining. + * + * @param tagCount + * the {@code tagCount} to set + * @return a reference to this Builder + */ + public Builder withTagCount(List tagCount) + { + this.tagCount = tagCount; + return this; + } + + /** + * Returns a {@code UxModel} built from the parameters previously set. + * + * @return a {@code UxModel} built with parameters of this {@code UxModel.Builder} + */ + public UxModel build() + { + return new UxModel(this); + } + + /** + * Sets the {@code projectName} and returns a reference to this Builder enabling method chaining. + * + * @param projectName + * the {@code projectName} to set + * @return a reference to this Builder + */ + public Builder withProjectName(String projectName) + { + this.projectName = projectName; + return this; + } + } + +} // UxModel diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxSpecItem.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxSpecItem.java new file mode 100644 index 00000000..d2993c5e --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxSpecItem.java @@ -0,0 +1,339 @@ +package org.itsallcode.openfasttrace.report.ux.model; + +import org.itsallcode.openfasttrace.api.core.LinkedSpecificationItem; + +import java.util.ArrayList; +import java.util.List; + +public class UxSpecItem +{ + private final int index; + private final int typeIndex; + private final String title; + private final String name; + private final String id; + private final List tagIndex; + private final List providesIndex; + private final List neededTypeIndex; + private final List coveredIndex; + private final List uncoveredIndex; + private final List coveringIndex; + private final List coveredByIndex; + private final List dependsIndex; + private final int statusId; + private final List path; + private final LinkedSpecificationItem item; + + private UxSpecItem(Builder builder) { + index = builder.index; + typeIndex = builder.typeIndex; + title = builder.title; + name = builder.name; + id = builder.id; + tagIndex = builder.tagIndex; + providesIndex = builder.providesIndex; + neededTypeIndex = builder.neededTypeIndex; + coveredIndex = builder.coveredIndex; + uncoveredIndex = builder.uncoveredIndex; + coveringIndex = builder.coveringIndex; + coveredByIndex = builder.coveredByIndex; + dependsIndex = builder.dependsIndex; + statusId = builder.statusId; + path = builder.path; + item = builder.item; + } + + public int getIndex() { + return index; + } + + public int getTypeIndex() { + return typeIndex; + } + + public String getTitle() { + return title; + } + + public String getName() { + return name; + } + + public String getId() { + return id; + } + + public List getTagIndex() { + return tagIndex; + } + + public List getProvidesIndex() { + return providesIndex; + } + + public List getNeededTypeIndex() { + return neededTypeIndex; + } + + public List getCoveredIndex() { + return coveredIndex; + } + + public List getUncoveredIndex() { + return uncoveredIndex; + } + + public List getCoveringIndex() { + return coveringIndex; + } + + public List getCoveredByIndex() { + return coveredByIndex; + } + + public List getDependsIndex() { + return dependsIndex; + } + + public int getStatusId() { + return statusId; + } + + public List getPath() { + return path; + } + + public LinkedSpecificationItem getItem() { + return item; + } + + /** + * {@code UxSpecItem} builder static inner class. + */ + public static final class Builder { + private int index; + private int typeIndex; + private String title; + private String name; + private String id; + private List tagIndex = new ArrayList<>(); + private List providesIndex = new ArrayList<>(); + private List neededTypeIndex; + private List coveredIndex; + private List uncoveredIndex = new ArrayList<>(); + private List coveringIndex; + private List coveredByIndex; + private List dependsIndex = new ArrayList<>(); + private int statusId; + private List path = new ArrayList<>(); + private LinkedSpecificationItem item; + + private Builder() { + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Sets the {@code index} and returns a reference to this Builder enabling method chaining. + * + * @param index + * the {@code index} to set + * @return a reference to this Builder + */ + public Builder withIndex(int index) { + this.index = index; + return this; + } + + /** + * Sets the {@code typeIndex} and returns a reference to this Builder enabling method chaining. + * + * @param typeIndex + * the {@code typeIndex} to set + * @return a reference to this Builder + */ + public Builder withTypeIndex(int typeIndex) { + this.typeIndex = typeIndex; + return this; + } + + /** + * Sets the {@code title} and returns a reference to this Builder enabling method chaining. + * + * @param title + * the {@code title} to set + * @return a reference to this Builder + */ + public Builder withTitle(String title) { + this.title = title; + return this; + } + + /** + * Sets the {@code name} and returns a reference to this Builder enabling method chaining. + * + * @param name + * the {@code name} to set + * @return a reference to this Builder + */ + public Builder withName(String name) { + this.name = name; + return this; + } + + /** + * Sets the {@code id} and returns a reference to this Builder enabling method chaining. + * + * @param id + * the {@code id} to set + * @return a reference to this Builder + */ + public Builder withId(String id) { + this.id = id; + return this; + } + + /** + * Sets the {@code tagIndex} and returns a reference to this Builder enabling method chaining. + * + * @param tagIndex + * the {@code tagIndex} to set + * @return a reference to this Builder + */ + public Builder withTagIndex(List tagIndex) { + this.tagIndex = tagIndex; + return this; + } + + /** + * Sets the {@code providesIndex} and returns a reference to this Builder enabling method chaining. + * + * @param providesIndex + * the {@code providesIndex} to set + * @return a reference to this Builder + */ + public Builder withProvidesIndex(List providesIndex) { + this.providesIndex = providesIndex; + return this; + } + + /** + * Sets the {@code neededTypeIndex} and returns a reference to this Builder enabling method chaining. + * + * @param neededTypeIndex + * the {@code neededTypeIndex} to set + * @return a reference to this Builder + */ + public Builder withNeededTypeIndex(List neededTypeIndex) { + this.neededTypeIndex = neededTypeIndex; + return this; + } + + /** + * Sets the {@code coveredIndex} and returns a reference to this Builder enabling method chaining. + * + * @param coveredIndex + * the {@code coveredIndex} to set + * @return a reference to this Builder + */ + public Builder withCoveredIndex(List coveredIndex) { + this.coveredIndex = coveredIndex; + return this; + } + + /** + * Sets the {@code uncoveredIndex} and returns a reference to this Builder enabling method chaining. + * + * @param uncoveredIndex + * the {@code uncoveredIndex} to set + * @return a reference to this Builder + */ + public Builder withUncoveredIndex(List uncoveredIndex) { + this.uncoveredIndex = uncoveredIndex; + return this; + } + + /** + * Sets the {@code coveringIndex} and returns a reference to this Builder enabling method chaining. + * + * @param coveringIndex + * the {@code coveringIndex} to set + * @return a reference to this Builder + */ + public Builder withCoveringIndex(List coveringIndex) { + this.coveringIndex = coveringIndex; + return this; + } + + /** + * Sets the {@code coveredByIndex} and returns a reference to this Builder enabling method chaining. + * + * @param coveredByIndex + * the {@code coveredByIndex} to set + * @return a reference to this Builder + */ + public Builder withCoveredByIndex(List coveredByIndex) { + this.coveredByIndex = coveredByIndex; + return this; + } + + /** + * Sets the {@code dependsIndex} and returns a reference to this Builder enabling method chaining. + * + * @param dependsIndex + * the {@code dependsIndex} to set + * @return a reference to this Builder + */ + public Builder withDependsIndex(List dependsIndex) { + this.dependsIndex = dependsIndex; + return this; + } + + /** + * Sets the {@code statusId} and returns a reference to this Builder enabling method chaining. + * + * @param statusId + * the {@code statusId} to set + * @return a reference to this Builder + */ + public Builder withStatusId(int statusId) { + this.statusId = statusId; + return this; + } + + /** + * Sets the {@code path} and returns a reference to this Builder enabling method chaining. + * + * @param path + * the {@code path} to set + * @return a reference to this Builder + */ + public Builder withPath(List path) { + this.path = path; + return this; + } + + /** + * Sets the {@code item} and returns a reference to this Builder enabling method chaining. + * + * @param item + * the {@code item} to set + * @return a reference to this Builder + */ + public Builder withItem(LinkedSpecificationItem item) { + this.item = item; + return this; + } + + /** + * Returns a {@code UxSpecItem} built from the parameters previously set. + * + * @return a {@code UxSpecItem} built with parameters of this {@code UxSpecItem.Builder} + */ + public UxSpecItem build() { + return new UxSpecItem(this); + } + } +} diff --git a/reporter/ux/src/main/resources/META-INF/services/org.itsallcode.openfasttrace.api.report.ReporterFactory b/reporter/ux/src/main/resources/META-INF/services/org.itsallcode.openfasttrace.api.report.ReporterFactory new file mode 100644 index 00000000..a71f95f1 --- /dev/null +++ b/reporter/ux/src/main/resources/META-INF/services/org.itsallcode.openfasttrace.api.report.ReporterFactory @@ -0,0 +1 @@ +org.itsallcode.openfasttrace.report.ux.UxReporterFactory diff --git a/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/CollectorTest.java b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/CollectorTest.java new file mode 100644 index 00000000..3649d169 --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/CollectorTest.java @@ -0,0 +1,277 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.itsallcode.openfasttrace.api.core.ItemStatus; +import org.itsallcode.openfasttrace.api.core.LinkedSpecificationItem; +import org.itsallcode.openfasttrace.core.Linker; +import org.itsallcode.openfasttrace.report.ux.model.Coverage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.itsallcode.matcher.auto.AutoMatcher.containsInAnyOrder; + +class CollectorTest { + + private Collector collector = null; + + @BeforeEach + void setUp() { + collector = new Collector().collect(SampleData.LINKED_SAMPLE_ITEMS); + } + + @AfterEach + void tearDown() { + } + + // Collect SpecItems Types + + /** + * Test extract SpecItems types as unordered set via Collector.collectAllTypes. + */ + @Test + void testCollectAllTypes() { + final Set types = Collector.collectAllTypes(SampleData.LINKED_SAMPLE_ITEMS); + System.out.println("testCollectAllTypes " + types); + assertThat(types, containsInAnyOrder(SampleData.ORDERED_SAMPLE_TYPES.toArray())); + } + + @Test + void testCollectDependentTypes() { + final Map dependencies = Collector.collectDependentTypes( + SampleData.LINKED_SAMPLE_ITEMS); + System.out.println(dependencies); + } + + /** + * Test creates an ordered list of SpecItems types with Collector.createOrderedTypes injecting SpecItems with a + * cycle. + */ + @Test + void testCreateOrderedTypes() { + // Order sample items with a cycle + final List orderedTypes1 = Collector.createOrderedTypes(SampleData.LINKED_SAMPLE_ITEMS_CYCLE); + System.out.println(String.join(",", orderedTypes1)); + assertThat(orderedTypes1, contains(SampleData.ORDERED_SAMPLE_TYPES.toArray())); + + // Order sample items with a cycle in reverse order + final List reverseItems = new ArrayList<>(SampleData.LINKED_SAMPLE_ITEMS_CYCLE); + Collections.reverse(reverseItems); + final List orderedTypes2 = Collector.createOrderedTypes(reverseItems); + System.out.println(String.join(",", orderedTypes2)); + assertThat(orderedTypes2, contains(SampleData.ORDERED_SAMPLE_TYPES.toArray())); + } + + // Collect coverages + + /** + * Tests thatCollector.initializedCoverages return a Map with all SpecItem types set Coverage.NONE. + */ + @Test + void testInitializedCoverages() { + final Map coverages = Collector.initializedCoverages(SampleData.ORDERED_SAMPLE_TYPES); + System.out.println(coverages); + assertThat(coverages, allOf( + hasEntry("utest", Coverage.NONE), + hasEntry("fea", Coverage.NONE), + hasEntry("arch", Coverage.NONE), + hasEntry("req", Coverage.NONE) + )); + } + + /** + * Helper to produce tuples of all permutations of coverage types. + */ + public static Stream provideCoveragePermutations() { + return Arrays.stream(Coverage.values()).flatMap(firstCoverage -> + Arrays.stream(Coverage.values()).map(secondCoverage -> + Arguments.of(firstCoverage, secondCoverage) + )); + } + + /** + * Tests that Collector.mergeCoverType returns the fitting coverage for all permutations of coverage types. + */ + @ParameterizedTest + @MethodSource( "provideCoveragePermutations" ) + void testMergeCoverageType(final Coverage firstCoverage, final Coverage secondCoverage) { + System.out.println(firstCoverage + ", " + secondCoverage); + if( ( firstCoverage == Coverage.MISSING || secondCoverage == Coverage.MISSING ) ) { + assertThat(Collector.mergeCoverType(firstCoverage, secondCoverage), equalTo(Coverage.MISSING)); + } + else if( ( firstCoverage == Coverage.UNCOVERED || secondCoverage == Coverage.UNCOVERED ) ) { + assertThat(Collector.mergeCoverType(firstCoverage, secondCoverage), equalTo(Coverage.UNCOVERED)); + } + else if( firstCoverage == Coverage.COVERED || secondCoverage == Coverage.COVERED ) { + assertThat(Collector.mergeCoverType(firstCoverage, secondCoverage), equalTo(Coverage.COVERED)); + } + else { + assertThat(Collector.mergeCoverType(firstCoverage, secondCoverage), equalTo(Coverage.NONE)); + } + } + + /** + * Test that Collector.mergeCoverages with empty from return false and does not change to toCoverage. + */ + @Test + void testMergeCoverages() { + final Map expectedToCoverages = Map.of( + "fea", Coverage.COVERED, + "arch", Coverage.UNCOVERED, + "req", Coverage.NONE, + "utest", Coverage.NONE + ); + + final Map toCoverageT1 = new HashMap<>(SampleData.toCoverages); + final boolean result = Collector.mergeCoverages(SampleData.fromCoverages, toCoverageT1); + assertThat(result, is(true)); + assertThat(toCoverageT1, equalTo(expectedToCoverages)); + } + + /** + * Test that Collector.mergeCoverages with empty from return false and does not change to toCoverage. + */ + @Test + void testMergeCoveragesWithEmptyFrom() { + final Map toCoverageT1 = new HashMap<>(SampleData.toCoverages); + assertThat(Collector.mergeCoverages(null, toCoverageT1), is(false)); + assertThat(toCoverageT1, equalTo(SampleData.toCoverages)); + } + + /** + * Test updating (merging) an entry into Collector.itemCoverages with testUpdateItemCoverage. + */ + @Test + void testUpdateItemCoverageAddingFirstEntry() { + final LinkedSpecificationItem sampleItem = new Linker(List.of( + SampleData.item("req~req1", ItemStatus.APPROVED, Set.of("arch")) + )).link().get(0); + final Map sampleCoverages = new HashMap<>(Map.of( + "fea", Coverage.NONE, + "req", Coverage.NONE, + "arch", Coverage.UNCOVERED, + "utest", Coverage.COVERED + )); + + final Collector collector = new Collector().collect(List.of()); + collector.itemCoverages.add(0, sampleCoverages); + collector.updateItemCoverage(0, sampleItem.getArtifactType(), Coverage.COVERED, sampleCoverages); + + final List> result = collector.getItemCoverages(); + assertThat(result.size(), is(1)); + assertThat(result.get(0), equalTo(Map.of( + "fea", Coverage.NONE, + "req", Coverage.COVERED, + "arch", Coverage.UNCOVERED, + "utest", Coverage.COVERED + ))); + } + + /** + * Test collected item coverage with {@link SampleData#SAMPLE_ITEMS}. + */ + @Test + void testItemCoverages() { + final Coverage[][] expectedCoverages = { + { Coverage.COVERED, Coverage.COVERED, Coverage.UNCOVERED, Coverage.MISSING }, + { Coverage.NONE, Coverage.COVERED, Coverage.COVERED, Coverage.COVERED }, + { Coverage.NONE, Coverage.COVERED, Coverage.UNCOVERED, Coverage.MISSING }, + { Coverage.NONE, Coverage.NONE, Coverage.COVERED, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.COVERED, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.UNCOVERED, Coverage.MISSING }, + { Coverage.NONE, Coverage.NONE, Coverage.NONE, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.NONE, Coverage.COVERED }, + }; + + final List> coverages = collector + .collect(SampleData.LINKED_SAMPLE_ITEMS) + .getItemCoverages(); + validateCoverages(SampleData.LINKED_SAMPLE_ITEMS, coverages, expectedCoverages); +/* + + System.out.println("0:" + coverages.get(0) + " of " + SAMPLE_ITEMS.get(0).getId()); + assertThat(coverages.get(0), + allOf(SampleData.coverages(Coverage.COVERED, Coverage.COVERED, Coverage.UNCOVERED, Coverage.COVERED))); + System.out.println("1:" + coverages.get(1) + " of " + SAMPLE_ITEMS.get(1).getId()); + assertThat(coverages.get(1), + allOf(SampleData.coverages(Coverage.NONE, Coverage.COVERED, Coverage.COVERED, Coverage.COVERED))); + System.out.println("2:" + coverages.get(2) + " of " + SAMPLE_ITEMS.get(2).getId()); + assertThat(coverages.get(2), + allOf(SampleData.coverages(Coverage.NONE, Coverage.COVERED, Coverage.UNCOVERED, Coverage.NONE))); + System.out.println("3:" + coverages.get(3) + " of " + SAMPLE_ITEMS.get(3).getId()); + assertThat(coverages.get(3), + allOf(SampleData.coverages(Coverage.NONE, Coverage.NONE, Coverage.COVERED, Coverage.COVERED))); + System.out.println("4:" + coverages.get(4) + " of " + SAMPLE_ITEMS.get(4).getId()); + assertThat(coverages.get(4), + allOf(SampleData.coverages(Coverage.NONE, Coverage.NONE, Coverage.COVERED, Coverage.COVERED))); + System.out.println("5:" + coverages.get(5) + " of " + SAMPLE_ITEMS.get(5).getId()); + assertThat(coverages.get(5), + allOf(SampleData.coverages(Coverage.NONE, Coverage.NONE, Coverage.UNCOVERED, Coverage.MISSING))); + System.out.println("6:" + coverages.get(6) + " of " + SAMPLE_ITEMS.get(6).getId()); + assertThat(coverages.get(6), + allOf(SampleData.coverages(Coverage.NONE, Coverage.NONE, Coverage.NONE, Coverage.COVERED))); + System.out.println("7:" + coverages.get(7) + " of " + SAMPLE_ITEMS.get(7).getId()); + assertThat(coverages.get(7), + allOf(SampleData.coverages(Coverage.NONE, Coverage.NONE, Coverage.NONE, Coverage.COVERED))); + + */ + } + + /** + * Test collected item coverage with {@link SampleData#LINKED_SAMPLE_ITEMS_CYCLE}. + */ + @Test + void testItemCoveragesWithCycle() { + final Coverage[][] expectedCoverages = { + { Coverage.COVERED, Coverage.COVERED, Coverage.UNCOVERED, Coverage.MISSING }, + { Coverage.NONE, Coverage.COVERED, Coverage.COVERED, Coverage.COVERED }, + { Coverage.NONE, Coverage.COVERED, Coverage.UNCOVERED, Coverage.MISSING }, + { Coverage.NONE, Coverage.NONE, Coverage.COVERED, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.COVERED, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.UNCOVERED, Coverage.MISSING }, + { Coverage.NONE, Coverage.NONE, Coverage.COVERED, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.NONE, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.NONE, Coverage.COVERED }, + { Coverage.NONE, Coverage.NONE, Coverage.NONE, Coverage.COVERED } + }; + + final List> coverages = collector + .collect(SampleData.LINKED_SAMPLE_ITEMS_CYCLE) + .getItemCoverages(); + validateCoverages(SampleData.LINKED_SAMPLE_ITEMS_CYCLE, coverages, expectedCoverages); + } + + private void validateCoverages(final List specItems, + final List> returnedCoverages, + final Coverage[][] expectedCoverages) { + for( int index = 0; index < expectedCoverages.length; index++ ) { + validateCoverage(index, specItems, returnedCoverages, expectedCoverages[index]); + } + } + + + // + // Helper + + private void validateCoverage(int index, + List specItems, + List> returnedCoverages, + Coverage... expectedCoverages) { + final LinkedSpecificationItem specItem = specItems.get(index); + final Map returnedCoverage = returnedCoverages.get(index); + System.out.printf("%d: %s%s matching {%s}\n", index, + specItem.getArtifactType(), + returnedCoverage, + Arrays.stream(expectedCoverages).map(Coverage::toString).collect(Collectors.joining(","))); + assertThat(returnedCoverage, allOf(SampleData.coverages(expectedCoverages))); + } + +} // CollectorTest \ No newline at end of file diff --git a/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/SampleData.java b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/SampleData.java new file mode 100644 index 00000000..8facd575 --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/SampleData.java @@ -0,0 +1,146 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.itsallcode.openfasttrace.api.core.ItemStatus; +import org.itsallcode.openfasttrace.api.core.LinkedSpecificationItem; +import org.itsallcode.openfasttrace.api.core.SpecificationItem; +import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.core.Linker; +import org.itsallcode.openfasttrace.report.ux.model.Coverage; + +import java.util.*; +import java.util.stream.Collectors; + +public class SampleData { + + public static final String LONG_SAMPLE_CONTENT = + "Officia voluptate aliquip ullamco dolore irure sint occaecat dolore eu proident." + + " Lorem cupidatat dolore voluptate non nulla commodo sint. Aliquip velit anim tem" + + "por magna culpa in esse. Excepteur anim ea ex est anim minim esse ut. Deserunt e" + + "nim veniam amet quis veniam amet in velit esse. Pariatur ut aliquip ipsum dolore" + + " quis reprehenderit excepteur adipisicing.Reprehenderit laboris reprehenderit re" + + "prehenderit irure aute eiusmod fugiat dolore ipsum velit mollit cillum. Commodo " + + "minim dolore nisi nostrud enim nisi reprehenderit aliqua anim deserunt ea ut eli" + + "t. Aute Lorem quis elit proident veniam sunt duis aliquip. Duis duis ad nostrud " + + "adipisicing. Consequat laboris qui aute cillum do eu non. Tempor commodo adipisi" + + "cing eu exercitation laboris."; + + public static final List SAMPLE_TAGS = List.of( "v1", "v2", "v3" ); + + /** + * Coverage types in ordered from based on SAMPLE_ITEM linkage + */ + public static final List ORDERED_SAMPLE_TYPES = List.of("fea", "req", "arch", "utest"); + + /** + * Generated samples data with project name removed. + */ + public static final String SAMPLE_OUTPUT_RESOURCE = "sample_jsgenerator_result.js"; + + /** + * Sample for items on all level fea,req,arch,utest with upwards linkes + */ + public static final List SAMPLE_ITEMS = List.of( + item("fea~fea1", ItemStatus.APPROVED, Set.of("req")), + item("req~req1", ItemStatus.APPROVED, Set.of("arch"), Set.of("fea~fea1")), + item("req~req2", ItemStatus.APPROVED, Set.of("arch"), Set.of("fea~fea1")), + item("arch~arch1", ItemStatus.APPROVED, Set.of("utest"), Set.of("req~req1")), + item("arch~arch2", ItemStatus.APPROVED, Set.of("utest"), Set.of("req~req1")), + item("arch~arch3", ItemStatus.APPROVED, Set.of("utest"), Set.of("req~req2"),LONG_SAMPLE_CONTENT, SAMPLE_TAGS), + item("utest~utest1", ItemStatus.APPROVED, Set.of(), Set.of("arch~arch1")), + item("utest~utest2", ItemStatus.APPROVED, Set.of(), Set.of("arch~arch2")) + ); + public static final List LINKED_SAMPLE_ITEMS = new Linker(SAMPLE_ITEMS).link(); + /** + * Sample for items on all level fea,req,arch,utest with a circular link + */ + public static final List SAMPLE_ITEM_CYCLE = List.of( + item("fea~fea1", ItemStatus.APPROVED, Set.of("req")), + item("req~req1", ItemStatus.APPROVED, Set.of("arch"), Set.of("fea~fea1")), + item("req~req2", ItemStatus.APPROVED, Set.of("arch"), Set.of("fea~fea1")), + item("arch~arch1", ItemStatus.APPROVED, Set.of("utest"), Set.of("req~req1")), + item("arch~arch2", ItemStatus.APPROVED, Set.of("utest"), Set.of("req~req1")), + item("arch~arch3", ItemStatus.APPROVED, Set.of("utest"), Set.of("req~req2")), + item("arch~cycle", ItemStatus.APPROVED, Set.of("utest"), Set.of("utest~cycle")), + item("utest~utest1", ItemStatus.APPROVED, Set.of(), Set.of("arch~arch1")), + item("utest~utest2", ItemStatus.APPROVED, Set.of(), Set.of("arch~arch2")), + item("utest~cycle", ItemStatus.APPROVED, Set.of(), Set.of("arch~cycle")) + ); + public static final List LINKED_SAMPLE_ITEMS_CYCLE = new Linker(SAMPLE_ITEM_CYCLE).link(); + public static final Map fromCoverages = Map.of( + "fea", Coverage.COVERED, + "arch", Coverage.UNCOVERED, + "req", Coverage.NONE + ); + public static final Map toCoverages = Map.of( + "fea", Coverage.COVERED, + "arch", Coverage.COVERED, + "utest", Coverage.NONE + ); + + + // + // Helpers + + public static List>> coverages(final Coverage... coverage) { + final List stack = new ArrayList<>(Arrays.stream(coverage).toList()); + return ORDERED_SAMPLE_TYPES.stream().map(type -> + Matchers.hasEntry(type, !stack.isEmpty() ? stack.remove(0) : Coverage.NONE) + ).collect(Collectors.toList()); + } + + public static SpecificationItem item(final String id, final ItemStatus status, final Set needs) { + final SpecificationItemId specId = id(id); + SpecificationItem.Builder builder = SpecificationItem.builder() + .id(specId) + .title("Title " + id) + .description("Descriptive text for " + id) + .status(status); + needs.forEach(builder::addNeedsArtifactType); + + return builder.build(); + } + + public static SpecificationItem item(final String id, + final ItemStatus status, + final Set needs, + final Set coverages) { + final SpecificationItemId specId = id(id); + SpecificationItem.Builder builder = SpecificationItem.builder() + .id(specId) + .title("Title " + id) + .description("Descriptive text for " + id) + .status(status); + needs.forEach(builder::addNeedsArtifactType); + coverages.forEach(coverage -> builder.addCoveredId(id(coverage))); + + return builder.build(); + } + + public static SpecificationItem item(final String id, + final ItemStatus status, + final Set needs, + final Set coverages, + final String content, + final List tags) { + final SpecificationItemId specId = id(id); + SpecificationItem.Builder builder = SpecificationItem.builder() + .id(specId) + .title("Title " + id) + .description(content) + .status(status); + needs.forEach(builder::addNeedsArtifactType); + tags.forEach(builder::addTag); + coverages.forEach(coverage -> builder.addCoveredId(id(coverage))); + + return builder.build(); + } + + public static SpecificationItemId id(final String id) { + return id.matches("/~.*~") ? + new SpecificationItemId.Builder(id).build() : + new SpecificationItemId.Builder(id + "~1").build(); + } + +} // SampleData diff --git a/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/TestHelper.java b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/TestHelper.java new file mode 100644 index 00000000..585a1725 --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/TestHelper.java @@ -0,0 +1,68 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.hamcrest.Matcher; +import org.itsallcode.openfasttrace.api.ReportSettings; +import org.itsallcode.openfasttrace.api.core.LinkedSpecificationItem; +import org.itsallcode.openfasttrace.api.core.Trace; +import org.itsallcode.openfasttrace.api.report.Reportable; +import org.itsallcode.openfasttrace.api.report.ReporterContext; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.Matchers.equalTo; + +public class TestHelper +{ + /** + * Creates a {@link UxReporter} with a default Context. + * + * @param items The items to process + * @return a UxReporter + */ + public static Reportable createReporter(final List items) { + final UxReporterFactory factory = new UxReporterFactory(); + factory.init(new ReporterContext(ReportSettings.createDefault())); + final Trace trace = createTrace(items); + return factory.createImporter(trace); + } + + /** + * Create a {@link Trace} from a List of {@link LinkedSpecificationItem}. + * + * @param items The items to trace + * @return The Trace + */ + public static Trace createTrace(final List items) { + return Trace.builder() + .items(items) + .defectItems(new ArrayList<>()) + .build(); + } + + /** + * Matcher that equals against a test resource file. + * + * @param fileName The file beneath test/resources + * @return A matcher + * @throws IOException file does not exist + */ + public static Matcher equalsToResource( final String fileName ) throws IOException + { + return equalTo(new String(Files.readAllBytes(Paths.get("src/test/resources", fileName )))); + } + + /** + * Removes the generated project Name from the generated js file as it includes a timestamp. + * + * @param generatedText The generated js + * @return The generated js without the project name + */ + public static String removeProjectNameFromJs( final String generatedText ) { + return generatedText.replaceFirst("(?m)projectName: '[^']*',","projectName: '',"); + } + +} // TestHelper diff --git a/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/UxReporterFactoryTest.java b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/UxReporterFactoryTest.java new file mode 100644 index 00000000..b9e8988b --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/UxReporterFactoryTest.java @@ -0,0 +1,35 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.itsallcode.openfasttrace.api.core.LinkedSpecificationItem; +import org.itsallcode.openfasttrace.api.core.Trace; +import org.itsallcode.openfasttrace.api.report.Reportable; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.itsallcode.openfasttrace.report.ux.TestHelper.createReporter; +import static org.junit.jupiter.api.Assertions.*; + +class UxReporterFactoryTest +{ + @Test + public void testFormat() + { + final UxReporterFactory factory = new UxReporterFactory(); + assertTrue(factory.supportsFormat("ux")); + assertFalse(factory.supportsFormat("plain")); + assertFalse(factory.supportsFormat("html")); + assertFalse(factory.supportsFormat("aspec")); + } + + @Test + void factoryCreatesUxReporter() + { + final Reportable reporter = createReporter(SampleData.LINKED_SAMPLE_ITEMS); + assertThat(reporter, instanceOf(UxReporter.class)); + } + +} \ No newline at end of file diff --git a/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/UxReporterTest.java b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/UxReporterTest.java new file mode 100644 index 00000000..ac21a6c0 --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/UxReporterTest.java @@ -0,0 +1,28 @@ +package org.itsallcode.openfasttrace.report.ux; + +import org.hamcrest.Matchers; +import org.itsallcode.openfasttrace.api.report.Reportable; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; + +import static net.bytebuddy.matcher.ElementMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.matchesPattern; +import static org.itsallcode.openfasttrace.report.ux.TestHelper.createReporter; +import static org.itsallcode.openfasttrace.report.ux.TestHelper.createTrace; + +class UxReporterTest +{ + @Test + void generatedModelContainsProjectNameFromProperty() + { + System.setProperty("oftProjectName", "TestProject"); + final Reportable reporter = createReporter(SampleData.LINKED_SAMPLE_ITEMS); + final OutputStream outputStream = new ByteArrayOutputStream(); + reporter.renderToStream(outputStream); + final String output = outputStream.toString(); + assertThat(output, matchesPattern("(?s).*projectName *: *['\"]TestProject.*")); + } +} \ No newline at end of file diff --git a/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/generator/JsGeneratorTest.java b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/generator/JsGeneratorTest.java new file mode 100644 index 00000000..ea8a7bcd --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/generator/JsGeneratorTest.java @@ -0,0 +1,44 @@ +package org.itsallcode.openfasttrace.report.ux.generator; + +import org.itsallcode.openfasttrace.report.ux.Collector; +import org.itsallcode.openfasttrace.report.ux.SampleData; +import org.itsallcode.openfasttrace.report.ux.model.UxModel; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.itsallcode.openfasttrace.report.ux.SampleData.SAMPLE_OUTPUT_RESOURCE; +import static org.itsallcode.openfasttrace.report.ux.TestHelper.equalsToResource; +import static org.itsallcode.openfasttrace.report.ux.TestHelper.removeProjectNameFromJs; + +class JsGeneratorTest { + + @Test + void type() { + final IGenerator generator = new JsGenerator(); + assertThat( generator.type(), equalTo(JsGenerator.TYPE )); + + } + + @Test + void generate() throws IOException + { + final UxModel model = new Collector().collect(SampleData.LINKED_SAMPLE_ITEMS).getUxModel(); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + new JsGenerator().generate(out,model); + System.out.println(out); + final String outWithoutProjectName = removeProjectNameFromJs(out.toString()); + assertThat(outWithoutProjectName, equalsToResource(SAMPLE_OUTPUT_RESOURCE)); + } + + @Test + void regexp() { + String text = "'Users can extend OFT's features with plugins from third parties.'"; + String o = text.replace("'","\\\'").replaceAll("\n\r?|\r", "
"); + System.out.println(o); + } + +} // JsGeneratorTest \ No newline at end of file diff --git a/reporter/ux/src/test/resources/sample_jsgenerator_result.js b/reporter/ux/src/test/resources/sample_jsgenerator_result.js new file mode 100644 index 00000000..c2879dca --- /dev/null +++ b/reporter/ux/src/test/resources/sample_jsgenerator_result.js @@ -0,0 +1,202 @@ +(function (window,undefined) { + window.specitem = { + project: { + projectName: '', + types: ["fea", "req", "arch", "utest"], + tags: ["v1", "v2", "v3"], + status: ["approved", "proposed", "draft", "rejected"], + item_count: 8, + item_covered: 5, + item_uncovered: 3, + type_count: [1, 2, 3, 2], + uncovered_count: [0, 0, 1, 0], + status_count: [8, 0, 0, 0], + tag_count: [1, 1, 1], + }, + specitems: [ + { + index: 0, + type: 0, + title: 'Title fea~fea1', + name: 'fea1', + id: 'fea:fea1', + tags: [], + version: 1, + content: 'Descriptive text for fea~fea1', + provides: [], + needs: [1], + covered: [2, 2, 1, 3], + uncovered: [2, 3], + covering: [], + coveredBy: [1, 2], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + { + index: 1, + type: 1, + title: 'Title req~req1', + name: 'req1', + id: 'req:req1', + tags: [], + version: 1, + content: 'Descriptive text for req~req1', + provides: [0], + needs: [2], + covered: [0, 2, 2, 2], + uncovered: [], + covering: [0], + coveredBy: [3, 4], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + { + index: 2, + type: 1, + title: 'Title req~req2', + name: 'req2', + id: 'req:req2', + tags: [], + version: 1, + content: 'Descriptive text for req~req2', + provides: [0], + needs: [2], + covered: [0, 2, 1, 3], + uncovered: [2, 3], + covering: [0], + coveredBy: [5], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + { + index: 3, + type: 2, + title: 'Title arch~arch1', + name: 'arch1', + id: 'arch:arch1', + tags: [], + version: 1, + content: 'Descriptive text for arch~arch1', + provides: [1], + needs: [3], + covered: [0, 0, 2, 2], + uncovered: [], + covering: [1], + coveredBy: [6], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + { + index: 4, + type: 2, + title: 'Title arch~arch2', + name: 'arch2', + id: 'arch:arch2', + tags: [], + version: 1, + content: 'Descriptive text for arch~arch2', + provides: [1], + needs: [3], + covered: [0, 0, 2, 2], + uncovered: [], + covering: [1], + coveredBy: [7], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + { + index: 5, + type: 2, + title: 'Title arch~arch3', + name: 'arch3', + id: 'arch:arch3', + tags: [0, 1, 2], + version: 1, + content: + 'Officia voluptate aliquip ullamco dolore irure sint occaecat dolore eu proident. Lorem cupidatat dolore volu' + + 'ptate non nulla commodo sint. Aliquip velit anim tempor magna culpa in esse. Excepteur anim ea ex est anim m' + + 'inim esse ut. Deserunt enim veniam amet quis veniam amet in velit esse. Pariatur ut aliquip ipsum dolore qui' + + 's reprehenderit excepteur adipisicing.Reprehenderit laboris reprehenderit reprehenderit irure aute eiusmod f' + + 'ugiat dolore ipsum velit mollit cillum. Commodo minim dolore nisi nostrud enim nisi reprehenderit aliqua ani' + + 'm deserunt ea ut elit. Aute Lorem quis elit proident veniam sunt duis aliquip. Duis duis ad nostrud adipisic' + + 'ing. Consequat laboris qui aute cillum do eu non. Tempor commodo adipisicing eu exercitation laboris.', + provides: [1], + needs: [3], + covered: [0, 0, 1, 3], + uncovered: [2, 3], + covering: [2], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + { + index: 6, + type: 3, + title: 'Title utest~utest1', + name: 'utest1', + id: 'utest:utest1', + tags: [], + version: 1, + content: 'Descriptive text for utest~utest1', + provides: [2], + needs: [], + covered: [0, 0, 0, 2], + uncovered: [], + covering: [3], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + { + index: 7, + type: 3, + title: 'Title utest~utest2', + name: 'utest2', + id: 'utest:utest2', + tags: [], + version: 1, + content: 'Descriptive text for utest~utest2', + provides: [2], + needs: [], + covered: [0, 0, 0, 2], + uncovered: [], + covering: [4], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + }, + ] + } +})(window);