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/doc/ux_report_output.md b/doc/ux_report_output.md new file mode 100644 index 00000000..565e9731 --- /dev/null +++ b/doc/ux_report_output.md @@ -0,0 +1,156 @@ +# UX reporter output format + +## Overview + +The UX reporter output format is the native output format of the openfasttrace ux-reporter. +It is the input format for OpenFastTrace UX. + +## Data Structure + +The generated JavaScript object follows this structure: + +```javascript +window.specitem = { + project: { /* project metadata */ }, + specitems: [ /* array of specification items */ ] +} +``` + +## Project Metadata + +The `project` object contains high-level information about the specification project: + +| Field | Type | Description | +|-------|------|-------------| +| `projectName` | string | Name of the project | +| `types` | string[] | Array of artifact types (e.g., "itest", "feat", "req", "arch", "utest") | +| `tags` | string[] | Array of all tags used in the project | +| `status` | string[] | Array of possible item statuses ("approved", "proposed", "draft", "rejected") | +| `wrongLinkNames` | string[] | Array of wrong link type names ("version", "orphaned", "unwanted") | +| `item_count` | number | Total number of specification items | +| `item_covered` | number | Number of items that are covered | +| `item_uncovered` | number | Number of items that are uncovered | +| `type_count` | number[] | Count of items per type (indexed by type array) | +| `uncovered_count` | number[] | Count of uncovered items per type | +| `status_count` | number[] | Count of items per status | +| `tag_count` | number[] | Count of items per tag | + +## Specitems Array Entries + +Each entry in the `specitems` array represents a single specification item with the following structure: + +### Basic Properties + +| Field | Type | Description | +|-------|------|-------------| +| `index` | number | Unique sequential index of the item in the array | +| `type` | number | Index into the `types` array indicating the item type | +| `title` | string | Full title of the specification item | +| `name` | string | Short name identifier of the item | +| `id` | string | Full unique identifier in format "type:name[:version]" | +| `tags` | number[] | Array of indices into the `tags` array | +| `version` | number | Revision number of the specification item | +| `status` | number | Index into the `status` array | + +### Content Properties + +| Field | Type | Description | +|-------|------|-------------| +| `content` | string | Description/content of the specification item | +| `comments` | string | Additional comments for the item | +| `path` | string[] | File path components where the item is defined | +| `sourceFile` | string | Source file path where the item is located | +| `sourceLine` | number | Line number in the source file (0 if not available) | + +### Traceability Properties + +| Field | Type | Description | +|-------|------|-------------| +| `provides` | number[] | Array of type indices that this item provides coverage for | +| `needs` | number[] | Array of type indices that this item needs coverage from | +| `covered` | number[] | Array of Coverage enum IDs per type (0=NONE, 1=UNCOVERED, 2=COVERED, 3=MISSING) | +| `uncovered` | number[] | Array of type indices that are uncovered or missing for this item | +| `covering` | number[] | Array of indices of items that this item covers | +| `coveredBy` | number[] | Array of indices of items that cover this item | +| `depends` | number[] | Array of indices of items that this item depends on | + +### Link Validation Properties + +| Field | Type | Description | +|-------|------|-------------| +| `wrongLinkTypes` | number[] | Array of indices into `wrongLinkNames` for invalid link types | +| `wrongLinkTargets` | string[] | Array of invalid link targets with format "target[reason]" | + +## Example Entry + +```javascript +{ + index: 0, + type: 2, + title: 'Title fea~fea1', + name: 'fea1', + id: 'fea:fea1', + tags: [], + version: 1, + content: 'Descriptive text for fea~fea1', + provides: [], + needs: [3], + covered: [0, 0, 2, 2, 1, 3], + uncovered: [4, 5], + covering: [], + coveredBy: [1, 2], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [] +} +``` + +## Data Interpretation + +### Index-Based References + +Most numeric arrays in the specitems use indices to reference: +- **Type indices**: Reference the `project.types` array +- **Tag indices**: Reference the `project.tags` array +- **Status indices**: Reference the `project.status` array +- **Item indices**: Reference other items in the `specitems` array +- **Wrong link type indices**: Reference the `project.wrongLinkNames` array + +### Coverage Arrays + +The `covered` array contains Coverage enum IDs per type, where each position corresponds to a specItem type in the `project.types` array. The Coverage enum values are: +- **0 = NONE**: No coverage relationship exists for this type +- **1 = UNCOVERED**: The specItem is not fully covered +- **2 = COVERED**: The specItem is fully covered +- **3 = MISSING**: These coverage type are needed by this specItems or specItems that cover this specItem (deep coverage) but are not covered + +For example, if `project.types = ["itest", "feat", "fea", "req", "arch", "utest"]` and `covered = [0, 0, 2, 2, 1, 3]`, this means: +- itest: NONE (no coverage relationship) +- feat: NONE (no coverage relationship) +- fea: COVERED (coverage is complete) +- req: COVERED (coverage is complete) +- arch: UNCOVERED (coverage required but missing) +- utest: MISSING (coverage exists but has issues) + +### Wrong Link Targets Format + +Wrong link targets are formatted as `"target[reason]"` where: +- `target` is the invalid link target specification +- `reason` explains why the link is invalid (e.g., "orphaned", "unwanted coverage", "outdated coverage") + +Example: `"itest:itest_wrong_type[unwanted coverage]"` + +## Usage Notes + +- All string values are properly escaped for JavaScript (quotes, HTML entities) +- Long content strings are automatically wrapped across multiple lines with string concatenation +- Empty arrays and strings indicate no data for that property +- The data structure is designed for efficient lookup and filtering in web interfaces +- Indices provide memory-efficient references while maintaining data integrity + +This data structure enables comprehensive traceability analysis and visualization in OpenFastTrace-UX applications. 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..e3a8fb3c 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.3.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..7a3bb1a1 --- /dev/null +++ b/reporter/ux/src/main/java/module-info.java @@ -0,0 +1,17 @@ +/** + * 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; + requires java.desktop; + requires jdk.jfr; + requires java.xml.crypto; + requires java.xml; + + 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..8a6852ad --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/Collector.java @@ -0,0 +1,647 @@ +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 org.itsallcode.openfasttrace.report.ux.model.WrongLinkType; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import static java.util.Map.entry; + +/** + * 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 wrongLinkTypes = 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 final List wrongLinkCount = 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()) + .withWrongLinkType(wrongLinkTypes) + .withTypeCount(typeCount) + .withUncoveredCount(uncoveredCounts) + .withStatusCount(statusCount) + .withTagCount(tagCount) + .withWrongLinkCount(wrongLinkCount) + .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()) + .withWrongLinkTypes(getWrongLinkTypeIndexes(item)) + .withWrongLinkTargets(getWrongLinkTypeByTargets(item)) + //.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 getWrongLinkTypeIndexes(final LinkedSpecificationItem item) + { + return item.getLinks().keySet().stream() + .map(WrongLinkType::toWrongLinkType) + .filter(WrongLinkType::isValid).distinct() + .map(wrongLinkTypes::indexOf) + .toList(); + } + + private Map getWrongLinkTypeByTargets(final LinkedSpecificationItem item) + { + final Set acceptedStatusTypes = Set.of(LinkStatus.ORPHANED, LinkStatus.AMBIGUOUS, + LinkStatus.COVERED_UNWANTED, LinkStatus.COVERED_OUTDATED, LinkStatus.COVERED_PREDATED); + + final Map, LinkStatus> statusByLinkTargets = item.getLinks().entrySet().stream() + .filter(entry -> acceptedStatusTypes.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); + + return statusByLinkTargets.entrySet().stream() + .flatMap(entry -> entry.getKey().stream() + .map(targetItem -> entry(toId(targetItem), entry.getValue().toString()))) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + } + + 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)); + wrongLinkTypes.clear(); + wrongLinkTypes.addAll(collectWrongLinkTypes(items)); + + 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)); + + wrongLinkCount.clear(); + wrongLinkCount.addAll(collectWrongLinkCount(items, wrongLinkTypes)); + } + + /** + * @return Get all types of all specItems. + */ + static Set collectAllTypes(final List items) { + return items.stream().map(LinkedSpecificationItem::getArtifactType).collect(Collectors.toSet()); + } + + /** + * Collects all wrongLinkTypes that exist in the model. + * + * @param items + * All {@link LinkedSpecificationItem} + * @return A list of used types + */ + static List collectWrongLinkTypes(final List items) + { + return items.stream() + .map(item -> item.getLinks().keySet()) + .flatMap(Collection::stream) + .map(WrongLinkType::toWrongLinkType) + .filter(WrongLinkType::isValid) + .distinct() + .toList(); + } + + /** + * 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 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 + */ + 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; + } + + 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; + } + + /** + * Collects the number of wrong links for each wrong link type. + * + * @param items + * The items to process + * @param wrongLinkTypes + * The list of wrong link types to count + * @return A list of counts where the index corresponds to the wrong link type index + */ + static List collectWrongLinkCount(final List items, + final List wrongLinkTypes) + { + return wrongLinkTypes.stream() + .map(wrongLinkType -> items.stream() + .flatMap(item -> item.getLinks().entrySet().stream()) + .filter(entry -> WrongLinkType.toWrongLinkType(entry.getKey()) == wrongLinkType) + .mapToInt(entry -> entry.getValue().size()) + .sum()) + .toList(); + } + + + // 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 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..4a35ed31 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/generator/JsGenerator.java @@ -0,0 +1,178 @@ +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 org.itsallcode.openfasttrace.report.ux.model.WrongLinkType; + +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("wronglinkNames", model.getWrongLinkTypes().stream().map(WrongLinkType::toString).toList()); + 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()); + println("wronglink_count", model.getWrongLinkCount()); + 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()); + println("wrongLinkTypes", item.getWrongLinkTypes()); + println("wrongLinkTargets", + item.getWrongLinkTargets().entrySet().stream().map(entry -> + String.format("%s[%s]", entry.getKey(), entry.getValue())).toList() + ); + 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..4177807e --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxModel.java @@ -0,0 +1,375 @@ +package org.itsallcode.openfasttrace.report.ux.model; + +import org.itsallcode.openfasttrace.api.core.ItemStatus; +import org.itsallcode.openfasttrace.api.core.SpecificationItem; + +import java.util.List; + +/** + * 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 List wrongLinkTypes; + + 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 final List wrongLinkCount; + + private UxModel(Builder builder) + { + projectName = builder.projectName; + artifactTypes = builder.artifactTypes; + tags = builder.tags; + statusNames = builder.statusNames; + wrongLinkTypes = builder.wrongLinkTypes; + numberOfSpecItems = builder.numberOfSpecItems; + uncoveredSpecItems = builder.uncoveredSpecItems; + items = builder.items; + typeCount = builder.typeCount; + uncoveredCount = builder.uncoveredCount; + statusCount = builder.statusCount; + tagCount = builder.tagCount; + wrongLinkCount = builder.wrongLinkCount; + } + + 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.wrongLinkTypes = copy.getWrongLinkTypes(); + 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(); + builder.wrongLinkCount = copy.getWrongLinkCount(); + 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 The names of the wrongLink type names find in specItems. + */ + public List getWrongLinkTypes() + { + return wrongLinkTypes; + } + + /** + * @return items within the model + */ + public List getItems() { + return items; + } + + /** + * @return numbers of items by type index + */ + public List getTypeCount() + { + return typeCount; + } + + /** + * @return covered count per specObject type + */ + public List getUncoveredCount() + { + return uncoveredCount; + } + + /** + * @return numbers of items by status index + */ + public List getStatusCount() + { + return statusCount; + } + + /** + * @return numbers of items by status index + */ + public List getTagCount() + { + return tagCount; + } + + /** + * @return numbers of wrong links + */ + public List getWrongLinkCount() + { + return wrongLinkCount; + } + + /** + * {@code UxModel} builder static inner class. + */ + public static final class Builder + { + private List artifactTypes; + private List tags; + private List statusNames; + private List wrongLinkTypes; + private int numberOfSpecItems; + private int uncoveredSpecItems; + private List items; + private List typeCount; + private List uncoveredCount; + private List statusCount; + private List tagCount; + private List wrongLinkCount; + 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 wrongLinkTypeNames} and returns a reference to this Builder enabling method chaining. + * + * @param wrongLinkType + * the {@code wrongLinkTypeNames} to set + * @return a reference to this Builder + */ + public Builder withWrongLinkType(List wrongLinkType) + { + this.wrongLinkTypes = wrongLinkType; + 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; + } + + /** + * Sets the {@code wrongLinkCount} and returns a reference to this Builder enabling method chaining. + * + * @param wrongLinkCount + * the {@code wrongLinkCount} to set + * @return a reference to this Builder + */ + public Builder withWrongLinkCount(List wrongLinkCount) + { + this.wrongLinkCount = wrongLinkCount; + 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..350b78c5 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/UxSpecItem.java @@ -0,0 +1,427 @@ +package org.itsallcode.openfasttrace.report.ux.model; + +import org.itsallcode.openfasttrace.api.core.LinkedSpecificationItem; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +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 wrongLinkTypes; + private final Map wrongLinkTargets; + 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; + wrongLinkTypes = builder.wrongLinkTypes; + wrongLinkTargets = builder.wrongLinkTargets; + path = builder.path; + item = builder.item; + } + + public static Builder builder(UxSpecItem copy) + { + Builder builder = new Builder(); + builder.index = copy.getIndex(); + builder.typeIndex = copy.getTypeIndex(); + builder.title = copy.getTitle(); + builder.name = copy.getName(); + builder.id = copy.getId(); + builder.tagIndex = copy.getTagIndex(); + builder.providesIndex = copy.getProvidesIndex(); + builder.neededTypeIndex = copy.getNeededTypeIndex(); + builder.coveredIndex = copy.getCoveredIndex(); + builder.uncoveredIndex = copy.getUncoveredIndex(); + builder.coveringIndex = copy.getCoveringIndex(); + builder.coveredByIndex = copy.getCoveredByIndex(); + builder.dependsIndex = copy.getDependsIndex(); + builder.statusId = copy.getStatusId(); + builder.wrongLinkTypes = copy.getWrongLinkTypes(); + builder.wrongLinkTargets = copy.getWrongLinkTargets(); + builder.path = copy.getPath(); + builder.item = copy.getItem(); + return builder; + } + + 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 getWrongLinkTypes() + { + return wrongLinkTypes; + } + + public Map getWrongLinkTargets() + { + return wrongLinkTargets; + } + + 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 wrongLinkTypes; + private Map wrongLinkTargets; + 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 wrongLinkTypes} and returns a reference to this Builder enabling method chaining. + * + * @param wrongLinkTypes + * the {@code wrongLinkTypes} to set + * @return a reference to this Builder + */ + public Builder withWrongLinkTypes(List wrongLinkTypes) + { + this.wrongLinkTypes = wrongLinkTypes; + return this; + } + + /** + * Sets the {@code wrongLinkTargets} and returns a reference to this Builder enabling method chaining. + * + * @param wrongLinkTargets + * the {@code wrongLinkTargets} to set + * @return a reference to this Builder + */ + public Builder withWrongLinkTargets(Map wrongLinkTargets) + { + this.wrongLinkTargets = wrongLinkTargets; + 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/java/org/itsallcode/openfasttrace/report/ux/model/WrongLinkType.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/WrongLinkType.java new file mode 100644 index 00000000..b12a6424 --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/model/WrongLinkType.java @@ -0,0 +1,67 @@ +package org.itsallcode.openfasttrace.report.ux.model; + +import org.itsallcode.openfasttrace.api.core.LinkStatus; + +/** + * {@link LinkStatus} used to filter SpecItems by bad link within OpenFastTrace UX. + */ +public enum WrongLinkType +{ + /** + * PREDATED or OUTDATED reference. + */ + WRONG_VERSION("version"), + + /** + * Unknown ID. + */ + ORPHANED("orphaned"), + + /** + * Not needed coverage type. + */ + UNWANTED("unwanted"), + + /** + * Not relevant + */ + NONE(""); + + private final String text; + + /** + * Tranform a {@link LinkStatus} to a WrongLinkType + * + * @param linkStatus The status to convert + * @return This WrongLinkType + */ + public static WrongLinkType toWrongLinkType( final LinkStatus linkStatus ) { + return switch (linkStatus) + { + case PREDATED, OUTDATED -> WRONG_VERSION; + case ORPHANED, AMBIGUOUS -> ORPHANED; + case UNWANTED -> UNWANTED; + default -> NONE; + }; + } + + WrongLinkType(final String text) { + this.text = text; + } + + public boolean isValid() { + return this != NONE; + } + + public String getText() + { + return text; + } + + @Override + public String toString() + { + return text; + } + +} // WrongLinkType diff --git a/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/package-info.java b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/package-info.java new file mode 100644 index 00000000..4cf368be --- /dev/null +++ b/reporter/ux/src/main/java/org/itsallcode/openfasttrace/report/ux/package-info.java @@ -0,0 +1,196 @@ +/** + * OpenFastTrace UX Reporter Package. + * + *

This package provides the UX (User Experience) reporter functionality for OpenFastTrace, + * which generates JavaScript data structures for interactive web-based visualization and analysis of specification + * traceability.

+ * + *

Overview

+ * + *

The UX reporter transforms OpenFastTrace's internal specification item model into a + * JavaScript data structure that can be consumed by web applications for interactive traceability visualization. The + * main components are:

+ * + *
    + *
  • {@link org.itsallcode.openfasttrace.report.ux.UxReporter} - Main reporter implementation
  • + *
  • {@link org.itsallcode.openfasttrace.report.ux.Collector} - Transforms specification items into UX model
  • + *
  • {@link org.itsallcode.openfasttrace.report.ux.generator.JsGenerator} - Generates JavaScript output
  • + *
  • {@link org.itsallcode.openfasttrace.report.ux.model.UxModel} - Container for project metadata and items
  • + *
  • {@link org.itsallcode.openfasttrace.report.ux.model.UxSpecItem} - Individual specification item data
  • + *
+ * + *

Generated JavaScript Data Structure

+ * + *

The {@link org.itsallcode.openfasttrace.report.ux.generator.JsGenerator} produces a JavaScript object + * with the following structure:

+ * + *
{@code
+ * window.specitem = {
+ *   project: { project metadata  },
+ *   specitems: [ array of specification items
+ * }
+ * }
+ * + *

The {@code project} object contains high-level information about the specification project:

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Project Object Fields
FieldTypeDescription
projectNamestringName of the project
typesstring[]Array of artifact types (e.g., "itest", "feat", "req", "arch", "utest")
tagsstring[]Array of all tags used in the project
statusstring[]Array of possible item statuses ("approved", "proposed", "draft", "rejected")
wrongLinkNamesstring[]Array of wrong link type names ("version", "orphaned", "unwanted")
item_countnumberTotal number of specification items
item_coverednumberNumber of items that are covered
item_uncoverednumberNumber of items that are uncovered
type_countnumber[]Count of items per type (indexed by types array)
uncovered_countnumber[]Count of uncovered items per type
status_countnumber[]Count of items per status
tag_countnumber[]Count of items per tag
+ * + *

Specification Items Array

+ * + *

Each entry in the {@code specitems} array represents a single specification item with the following structure:

+ * + *

Basic Properties

+ * + * + * + * + * + * + * + * + * + * + * + *
Basic Properties of Specification Items
FieldTypeDescription
indexnumberUnique sequential index of the item in the array
typenumberIndex into the types array indicating the item type
titlestringFull title of the specification item
namestringShort name identifier of the item
idstringFull unique identifier in format "type:name[:version]"
tagsnumber[]Array of indices into the tags array
versionnumberRevision number of the specification item
statusnumberIndex into the status array
+ * + *

Content Properties

+ * + * + * + * + * + * + * + * + *
Content Properties of Specification Items
FieldTypeDescription
contentstringDescription/content of the specification item
commentsstringAdditional comments for the item
pathstring[] File path components where the item is defined
sourceFilestring Source file path where the item is located
sourceLinenumber Line number in the source file (0 if not available)
+ * + *

Traceability Properties

+ * + * + * + * + * + * + * + * + * + * + *
Traceability Properties of Specification Items
FieldTypeDescription
providesnumber[]Array of type indices that this item provides coverage for
needsnumber[]Array of type indices that this item needs coverage from
coverednumber[]Array of {@link org.itsallcode.openfasttrace.report.ux.model.Coverage} enum IDs per type (0=NONE, 1=UNCOVERED, 2=COVERED, 3=MISSING)
uncoverednumber[]Array of type indices that are uncovered or missing for this item
coveringnumber[]Array of indices of items that this item covers
coveredBynumber[]Array of indices of items that cover this item
dependsnumber[]Array of indices of items that this item depends on
+ * + *

Link Validation Properties

+ * + * + * + * + * + *
Link Validation Properties of Specification Items
FieldTypeDescription
wrongLinkTypesnumber[]Array of indices into wrongLinkNames for invalid link types
wrongLinkTargetsstring[]Array of invalid link targets with format "target[reason]"
+ * + *

Data Interpretation

+ * + *

Index-Based References

+ *

Most numeric arrays in the specitems use indices to reference:

+ *
    + *
  • Type indices: Reference the {@code project.types} array
  • + *
  • Tag indices: Reference the {@code project.tags} array
  • + *
  • Status indices: Reference the {@code project.status} array
  • + *
  • Item indices: Reference other items in the {@code specitems} array
  • + *
  • Wrong link type indices: Reference the {@code project.wrongLinkNames} array
  • + *
+ * + *

Coverage Arrays

+ *

The {@code covered} array contains {@link org.itsallcode.openfasttrace.report.ux.model.Coverage} enum IDs per type, + * where each position corresponds to a type in the {@code project.types} array. + * The {@link org.itsallcode.openfasttrace.report.ux.model.Coverage} enum values are:

+ *
    + *
  • 0 = NONE: No coverage relationship exists for this type
  • + *
  • 1 = UNCOVERED: Coverage is required but missing for this type
  • + *
  • 2 = COVERED: Coverage is complete for this type
  • + *
  • 3 = MISSING: Coverage exists but has issues (e.g., missing target items)
  • + *
+ * + *

For example, if {@code project.types = ["itest", "feat", "fea", "req", "arch", "utest"]} and + * {@code covered = [0, 0, 2, 2, 1, 3]}, this means:

+ *
    + *
  • 0 = NONE: No coverage relationship exists for this type
  • + *
  • 1 = UNCOVERED: The specItem is not fully covered
  • + *
  • 2 = COVERED: The specItem is fully covered
  • + *
  • 3 = MISSING: These coverage type are needed by this specItems or specItems that cover this specItem + * (deep coverage) but are not covered
  • + *
+ * + *

Uncovered Arrays

+ *

The {@code uncovered} array is calculated by {@link org.itsallcode.openfasttrace.report.ux.Collector} toUncoveredIndexes() + * and contains type indices where the coverage state is either {@code UNCOVERED} or {@code MISSING}.

+ * + *

Wrong Link Targets Format

+ *

Wrong link targets are formatted as {@code "target[reason]"} where:

+ *
    + *
  • {@code target} is the invalid link target specification
  • + *
  • {@code reason} explains why the link is invalid (e.g., "orphaned", "unwanted coverage", "outdated coverage")
  • + *
+ *

Example: {@code "itest:itest_wrong_type[unwanted coverage]"}

+ * + *

Example JavaScript Output

+ * + *
{@code
+ * {
+ *   index: 0,
+ *   type: 2,
+ *   title: 'Title fea~fea1',
+ *   name: 'fea1',
+ *   id: 'fea:fea1',
+ *   tags: [],
+ *   version: 1,
+ *   content: 'Descriptive text for fea~fea1',
+ *   provides: [],
+ *   needs: [3],
+ *   covered: [0, 0, 2, 2, 1, 3],
+ *   uncovered: [4, 5],
+ *   covering: [],
+ *   coveredBy: [1, 2],
+ *   depends: [],
+ *   status: 0,
+ *   path: [],
+ *   sourceFile: '',
+ *   sourceLine: 0,
+ *   comments: '',
+ *   wrongLinkTypes: [],
+ *   wrongLinkTargets: []
+ * }
+ * }
+ * + *

Implementation Notes

+ * + *
    + *
  • All string values are properly escaped for JavaScript (quotes, HTML entities)
  • + *
  • Long content strings are automatically wrapped across multiple lines with string concatenation
  • + *
  • Empty arrays and strings indicate no data for that property
  • + *
  • The data structure is designed for efficient lookup and filtering in web interfaces
  • + *
  • Indices provide memory-efficient references while maintaining data integrity
  • + *
+ * + *

Usage

+ * + *

This data structure enables comprehensive traceability analysis and visualization in + * OpenFastTrace-UX web applications. The generated JavaScript can be consumed by frontend + * frameworks to create interactive dashboards, coverage reports, and traceability matrices.

+ * + * @since 4.2.0 + */ + +package org.itsallcode.openfasttrace.report.ux; 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..b6d27a22 --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/CollectorTest.java @@ -0,0 +1,575 @@ +package org.itsallcode.openfasttrace.report.ux; + +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.core.Linker; +import org.itsallcode.openfasttrace.report.ux.model.Coverage; +import org.itsallcode.openfasttrace.report.ux.model.UxSpecItem; +import org.itsallcode.openfasttrace.report.ux.model.WrongLinkType; +import org.junit.jupiter.api.AfterEach; +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; +import static org.itsallcode.openfasttrace.report.ux.SampleData.LINKED_SAMPLE_WRONG_LINKS; +import static org.itsallcode.openfasttrace.report.ux.model.WrongLinkType.*; + +class CollectorTest { + + @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 = new Collector() + .collect(SampleData.LINKED_SAMPLE_ITEMS) + .getItemCoverages(); + validateCoverages(SampleData.LINKED_SAMPLE_ITEMS, coverages, expectedCoverages); + } + + /** + * 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 = new Collector() + .collect(SampleData.LINKED_SAMPLE_ITEMS_CYCLE) + .getItemCoverages(); + validateCoverages(SampleData.LINKED_SAMPLE_ITEMS_CYCLE, coverages, expectedCoverages); + } + + @Test + void testLinkTypes() + { + final Collector collector = new Collector().collect(LINKED_SAMPLE_WRONG_LINKS); + assertThat(collector.getUxModel().getWrongLinkTypes(), containsInAnyOrder(WRONG_VERSION, UNWANTED, ORPHANED)); + }@Test + void testCollectWrongLinkTypesWithWrongLinks() + { + final List wrongLinkTypes = Collector.collectWrongLinkTypes(LINKED_SAMPLE_WRONG_LINKS); + + assertThat(wrongLinkTypes, hasSize(3)); + assertThat(wrongLinkTypes, containsInAnyOrder(WRONG_VERSION, ORPHANED, UNWANTED)); + } + + @Test + void testCollectWrongLinkTypesWithNoWrongLinks() + { + final List wrongLinkTypes = Collector.collectWrongLinkTypes(SampleData.LINKED_SAMPLE_ITEMS); + + assertThat(wrongLinkTypes, hasSize(0)); + } + + @Test + void testCollectWrongLinkTypesEmptyItems() + { + final List wrongLinkTypes = Collector.collectWrongLinkTypes(List.of()); + + assertThat(wrongLinkTypes, hasSize(0)); + } + + @Test + void testWrongLinks() + { + final Map goldenWrongLinkTypeMapping = Map.of( + "req~req_lower_version~1", WRONG_VERSION, + "req~req_higher_version~1", WRONG_VERSION, + "itest~itest_wrong_type~1", UNWANTED, + "itest~itest_dead_type~1", ORPHANED + ); + final Collector collector = new Collector().collect(LINKED_SAMPLE_WRONG_LINKS); + final List wrongLinkTypeMap = collector.getUxModel().getWrongLinkTypes(); + + for (final UxSpecItem item : collector.getUxItems()) + { + if ("feat".equals(item.getItem().getId().getArtifactType())) + continue; + + final String id = item.getItem().getId().toString(); + final int expectedTypeIndex = wrongLinkTypeMap.indexOf(goldenWrongLinkTypeMapping.get(id)); + System.out.printf("Item links %s %s%n", item.getId(), + item.getWrongLinkTypes().stream() + .map(index -> wrongLinkTypeMap.get(index).getText()) + .collect(Collectors.joining(",")) + ); + assertThat(item.getWrongLinkTypes(), hasItem(expectedTypeIndex)); + } + } + + @Test + void testWrongLinksByType() + { + final Map> goldenWrongLinkTypeMapping = Map.of( + "req:req_lower_version", List.of("feat:feat1:2[outdated]", "feat:feat1[orphaned]"), + "req:req_higher_version", List.of("feat:feat1:2[predated]", "feat:feat1:3[orphaned]"), + "itest:itest_wrong_type", List.of("feat:feat1:2[unwanted]"), + "itest:itest_dead_type", List.of("feat:feat2:3[orphaned]") + ); + + final Collector collector = new Collector().collect(LINKED_SAMPLE_WRONG_LINKS); + for (final UxSpecItem item : collector.getUxItems()) + { + if ("feat".equals(item.getItem().getId().getArtifactType())) + continue; + + final List goldenSample = goldenWrongLinkTypeMapping.get(item.getId()); + assertThat(goldenSample,notNullValue()); + Map targets = item.getWrongLinkTargets(); + for (Map.Entry target : targets.entrySet()) + { + final String targetValue = String.format("%s[%s]", target.getKey(), target.getValue()); + assertThat(targetValue,is(in(goldenSample))); + } + } + } + + @Test + void testCollectWrongLinkCountWithNoWrongLinks() + { + final List wrongLinkTypes = List.of(WRONG_VERSION, ORPHANED, UNWANTED); + final List wrongLinkCount = Collector.collectWrongLinkCount( + SampleData.LINKED_SAMPLE_ITEMS, wrongLinkTypes); + + assertThat(wrongLinkCount, hasSize(3)); + assertThat(wrongLinkCount, contains(0, 0, 0)); + } + + @Test + void testCollectWrongLinkCountWithWrongLinks() + { + final List wrongLinkTypes = Collector.collectWrongLinkTypes(LINKED_SAMPLE_WRONG_LINKS); + final List wrongLinkCount = Collector.collectWrongLinkCount( + LINKED_SAMPLE_WRONG_LINKS, wrongLinkTypes); + + // wrongLinkTypes should be in the order: [WRONG_VERSION, ORPHANED, UNWANTED] + // or could be in a different order, so we need to check based on the actual order + final int versionIndex = wrongLinkTypes.indexOf(WRONG_VERSION); + final int orphanedIndex = wrongLinkTypes.indexOf(ORPHANED); + final int unwantedIndex = wrongLinkTypes.indexOf(UNWANTED); + + assertThat(wrongLinkCount, hasSize(3)); + // 2 version wrong links (1 outdated + 1 predated) + assertThat(wrongLinkCount.get(versionIndex), is(2)); + // 3 orphaned links (1 from req_lower_version + 1 from req_higher_version + 1 from itest_dead_type) + assertThat(wrongLinkCount.get(orphanedIndex), is(3)); + // 1 unwanted link (from itest_wrong_type) + assertThat(wrongLinkCount.get(unwantedIndex), is(1)); + } + + @Test + void testCollectWrongLinkCountEmptyItems() + { + final List wrongLinkTypes = List.of(WRONG_VERSION, ORPHANED, UNWANTED); + final List wrongLinkCount = Collector.collectWrongLinkCount( + List.of(), wrongLinkTypes); + + assertThat(wrongLinkCount, hasSize(3)); + assertThat(wrongLinkCount, contains(0, 0, 0)); + } + + @Test + void testCollectWrongLinkCountEmptyWrongLinkTypes() + { + final List wrongLinkCount = Collector.collectWrongLinkCount( + LINKED_SAMPLE_WRONG_LINKS, List.of()); + + assertThat(wrongLinkCount, hasSize(0)); + } + + @Test + void testCollectWrongLinkCountIntegration() + { + final Collector collector = new Collector().collect(LINKED_SAMPLE_WRONG_LINKS); + final List wrongLinkCount = collector.getUxModel().getWrongLinkCount(); + final List wrongLinkTypes = collector.getUxModel().getWrongLinkTypes(); + + assertThat(wrongLinkCount, notNullValue()); + assertThat(wrongLinkCount, hasSize(wrongLinkTypes.size())); + + // Verify the total count matches expected + final int totalWrongLinks = wrongLinkCount.stream().mapToInt(Integer::intValue).sum(); + assertThat(totalWrongLinks, is(6)); // 2 version + 3 orphaned + 1 unwanted = 6 total + } + + // Collect Tag Count + + @Test + void testCollectTagCountWithTags() + { + final Map tagCount = Collector.collectTagCount(SampleData.LINKED_SAMPLE_ITEMS); + + assertThat(tagCount, notNullValue()); + assertThat(tagCount, hasEntry("v1", 1)); + assertThat(tagCount, hasEntry("v2", 1)); + assertThat(tagCount, hasEntry("v3", 1)); + assertThat(tagCount.size(), is(3)); + } + + @Test + void testCollectTagCountNoTags() + { + final List itemsWithoutTags = List.of( + SampleData.item("req~req1", ItemStatus.APPROVED, Set.of("arch")) + ); + final List linkedItems = new Linker(itemsWithoutTags).link(); + final Map tagCount = Collector.collectTagCount(linkedItems); + + assertThat(tagCount, notNullValue()); + assertThat(tagCount.isEmpty(), is(true)); + } + + @Test + void testCollectTagCountMultipleItemsWithSameTags() + { + final List itemsWithTags = List.of( + SampleData.item("req~req1", ItemStatus.APPROVED, Set.of("arch"), Set.of(), + "content", List.of("tag1", "tag2")), + SampleData.item("req~req2", ItemStatus.APPROVED, Set.of("arch"), Set.of(), + "content", List.of("tag1")), + SampleData.item("req~req3", ItemStatus.APPROVED, Set.of("arch"), Set.of(), + "content", List.of("tag2", "tag3")) + ); + final List linkedItems = new Linker(itemsWithTags).link(); + final Map tagCount = Collector.collectTagCount(linkedItems); + + assertThat(tagCount, hasEntry("tag1", 2)); + assertThat(tagCount, hasEntry("tag2", 2)); + assertThat(tagCount, hasEntry("tag3", 1)); + assertThat(tagCount.size(), is(3)); + } + + @Test + void testCollectTagCountEmptyItems() + { + final Map tagCount = Collector.collectTagCount(List.of()); + + assertThat(tagCount, notNullValue()); + assertThat(tagCount.isEmpty(), is(true)); + } + + @Test + void testCollectTagCountIntegration() + { + final Collector collector = new Collector().collect(SampleData.LINKED_SAMPLE_ITEMS); + final List tags = collector.getUxModel().getTags(); + final List tagCounts = collector.getUxModel().getTagCount(); + + assertThat(tags, hasSize(3)); + assertThat(tagCounts, hasSize(3)); + assertThat(tagCounts, everyItem(is(1))); // Each tag appears once in sample data + } + + // Collect Type Count + + @Test + void testCollectTypeCountWithTypes() + { + final List orderedTypes = SampleData.ORDERED_SAMPLE_TYPES; + final List typeCount = Collector.collectTypeCount( + SampleData.LINKED_SAMPLE_ITEMS, orderedTypes); + + assertThat(typeCount, hasSize(4)); + // fea: 1, req: 2, arch: 3, utest: 2 + assertThat(typeCount.get(orderedTypes.indexOf("fea")), is(1)); + assertThat(typeCount.get(orderedTypes.indexOf("req")), is(2)); + assertThat(typeCount.get(orderedTypes.indexOf("arch")), is(3)); + assertThat(typeCount.get(orderedTypes.indexOf("utest")), is(2)); + } + + @Test + void testCollectTypeCountEmptyItems() + { + final List orderedTypes = SampleData.ORDERED_SAMPLE_TYPES; + final List typeCount = Collector.collectTypeCount(List.of(), orderedTypes); + + assertThat(typeCount, hasSize(4)); + assertThat(typeCount, everyItem(is(0))); + } + + @Test + void testCollectTypeCountCountsForAllTypes() + { + final List typeCount = Collector.collectTypeCount( + SampleData.LINKED_SAMPLE_ITEMS, List.of("fea","req","arch","utest")); + + assertThat(typeCount, hasSize(4)); + } + + @Test + void testCollectTypeCountIntegration() + { + final Collector collector = new Collector().collect(SampleData.LINKED_SAMPLE_ITEMS); + final List types = collector.getUxModel().getArtifactTypes(); + final List typeCounts = collector.getUxModel().getTypeCount(); + + assertThat(types, hasSize(4)); + assertThat(typeCounts, hasSize(4)); + + final int totalItems = typeCounts.stream().mapToInt(Integer::intValue).sum(); + assertThat(totalItems, is(SampleData.LINKED_SAMPLE_ITEMS.size())); + } + + // Collect Status Count + + @Test + void testCollectStatusCountWithApprovedItems() + { + final List statusCount = Collector.collectStatusCount(SampleData.LINKED_SAMPLE_ITEMS); + + assertThat(statusCount, hasSize(ItemStatus.values().length)); + // All sample items are APPROVED (index 0) + assertThat(statusCount.get(ItemStatus.APPROVED.ordinal()), is(SampleData.LINKED_SAMPLE_ITEMS.size())); + assertThat(statusCount.get(ItemStatus.PROPOSED.ordinal()), is(0)); + assertThat(statusCount.get(ItemStatus.DRAFT.ordinal()), is(0)); + assertThat(statusCount.get(ItemStatus.REJECTED.ordinal()), is(0)); + } + + @Test + void testCollectStatusCountWithMixedStatuses() + { + final List mixedStatusItems = List.of( + SampleData.item("req~req1", ItemStatus.APPROVED, Set.of("arch")), + SampleData.item("req~req2", ItemStatus.PROPOSED, Set.of("arch")), + SampleData.item("req~req3", ItemStatus.DRAFT, Set.of("arch")), + SampleData.item("req~req4", ItemStatus.REJECTED, Set.of("arch")), + SampleData.item("req~req5", ItemStatus.APPROVED, Set.of("arch")) + ); + final List linkedItems = new Linker(mixedStatusItems).link(); + final List statusCount = Collector.collectStatusCount(linkedItems); + + assertThat(statusCount, hasSize(ItemStatus.values().length)); + assertThat(statusCount.get(ItemStatus.APPROVED.ordinal()), is(2)); + assertThat(statusCount.get(ItemStatus.PROPOSED.ordinal()), is(1)); + assertThat(statusCount.get(ItemStatus.DRAFT.ordinal()), is(1)); + assertThat(statusCount.get(ItemStatus.REJECTED.ordinal()), is(1)); + } + + @Test + void testCollectStatusCountEmptyItems() + { + final List statusCount = Collector.collectStatusCount(List.of()); + + assertThat(statusCount, hasSize(ItemStatus.values().length)); + assertThat(statusCount, everyItem(is(0))); + } + + @Test + void testCollectStatusCountIntegration() + { + final Collector collector = new Collector().collect(SampleData.LINKED_SAMPLE_ITEMS); + final List statusNames = collector.getUxModel().getStatusNames(); + final List statusCounts = collector.getUxModel().getStatusCount(); + + assertThat(statusNames, hasSize(ItemStatus.values().length)); + assertThat(statusCounts, hasSize(ItemStatus.values().length)); + + final int totalItems = statusCounts.stream().mapToInt(Integer::intValue).sum(); + assertThat(totalItems, is(SampleData.LINKED_SAMPLE_ITEMS.size())); + } + + // + // Helper + + + 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]); + } + } + + 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..8a587783 --- /dev/null +++ b/reporter/ux/src/test/java/org/itsallcode/openfasttrace/report/ux/SampleData.java @@ -0,0 +1,164 @@ +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; +import java.util.stream.Stream; + +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 + ); + + public static final List SAMPLE_WRONG_LINKS = List.of( + item("feat~feat1~2", ItemStatus.APPROVED, Set.of("req")), + item("req~req_lower_version~1", ItemStatus.APPROVED, Set.of("arch"), Set.of("feat~feat1~1")), + item("req~req_higher_version~1", ItemStatus.APPROVED, Set.of("arch"), Set.of("feat~feat1~3")), + item("itest~itest_wrong_type~1", ItemStatus.APPROVED, Set.of("arch"), Set.of("feat~feat1~2")), + item("itest~itest_dead_type~1", ItemStatus.APPROVED, Set.of("arch"), Set.of("feat~feat2~3")) + ); + + public static final List LINKED_SAMPLE_WRONG_LINKS = new Linker(SAMPLE_WRONG_LINKS).link(); + + public static final List SAMPLE_ITEMS_WRONG_LINKS = Stream.concat( + SAMPLE_ITEMS.stream(), + SAMPLE_WRONG_LINKS.stream() + ).toList(); + + public static final List LINKED_SAMPLE_ITEMS_WRONG_LINK = + new Linker(SAMPLE_ITEMS_WRONG_LINKS).link(); + + // + // 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.matches(".*~[0-9]+$") ? id : 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..67ae63e0 --- /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_WRONG_LINK).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..a7b23039 --- /dev/null +++ b/reporter/ux/src/test/resources/sample_jsgenerator_result.js @@ -0,0 +1,340 @@ +(function (window,undefined) { + window.specitem = { + project: { + projectName: '', + types: ["itest", "feat", "fea", "req", "arch", "utest"], + tags: ["v1", "v2", "v3"], + status: ["approved", "proposed", "draft", "rejected"], + wronglinkNames: ["version", "orphaned", "unwanted"], + item_count: 13, + item_covered: 5, + item_uncovered: 8, + type_count: [2, 1, 1, 4, 3, 2], + uncovered_count: [2, 0, 0, 2, 1, 0], + status_count: [13, 0, 0, 0], + tag_count: [1, 1, 1], + wronglink_count: [2, 3, 1], + }, + specitems: [ + { + index: 0, + type: 2, + title: 'Title fea~fea1', + name: 'fea1', + id: 'fea:fea1', + tags: [], + version: 1, + content: 'Descriptive text for fea~fea1', + provides: [], + needs: [3], + covered: [0, 0, 2, 2, 1, 3], + uncovered: [4, 5], + covering: [], + coveredBy: [1, 2], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 1, + type: 3, + title: 'Title req~req1', + name: 'req1', + id: 'req:req1', + tags: [], + version: 1, + content: 'Descriptive text for req~req1', + provides: [2], + needs: [4], + covered: [0, 0, 0, 2, 2, 2], + uncovered: [], + covering: [0], + coveredBy: [3, 4], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 2, + type: 3, + title: 'Title req~req2', + name: 'req2', + id: 'req:req2', + tags: [], + version: 1, + content: 'Descriptive text for req~req2', + provides: [2], + needs: [4], + covered: [0, 0, 0, 2, 1, 3], + uncovered: [4, 5], + covering: [0], + coveredBy: [5], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 3, + type: 4, + title: 'Title arch~arch1', + name: 'arch1', + id: 'arch:arch1', + tags: [], + version: 1, + content: 'Descriptive text for arch~arch1', + provides: [3], + needs: [5], + covered: [0, 0, 0, 0, 2, 2], + uncovered: [], + covering: [1], + coveredBy: [6], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 4, + type: 4, + title: 'Title arch~arch2', + name: 'arch2', + id: 'arch:arch2', + tags: [], + version: 1, + content: 'Descriptive text for arch~arch2', + provides: [3], + needs: [5], + covered: [0, 0, 0, 0, 2, 2], + uncovered: [], + covering: [1], + coveredBy: [7], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 5, + type: 4, + 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: [3], + needs: [5], + covered: [0, 0, 0, 0, 1, 3], + uncovered: [4, 5], + covering: [2], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 6, + type: 5, + title: 'Title utest~utest1', + name: 'utest1', + id: 'utest:utest1', + tags: [], + version: 1, + content: 'Descriptive text for utest~utest1', + provides: [4], + needs: [], + covered: [0, 0, 0, 0, 0, 2], + uncovered: [], + covering: [3], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 7, + type: 5, + title: 'Title utest~utest2', + name: 'utest2', + id: 'utest:utest2', + tags: [], + version: 1, + content: 'Descriptive text for utest~utest2', + provides: [4], + needs: [], + covered: [0, 0, 0, 0, 0, 2], + uncovered: [], + covering: [4], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: [], + }, + { + index: 8, + type: 1, + title: 'Title feat~feat1~2', + name: 'feat1', + id: 'feat:feat1:2', + tags: [], + version: 2, + content: 'Descriptive text for feat~feat1~2', + provides: [], + needs: [3], + covered: [0, 2, 0, 1, 3, 0], + uncovered: [3, 4], + covering: [], + coveredBy: [9, 10], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [], + wrongLinkTargets: ["itest:itest_wrong_type[unwanted coverage]", "req:req_lower_version[outdated coverage]", "req:req_higher_version[predated coverage]"], + }, + { + index: 9, + type: 3, + title: 'Title req~req_lower_version~1', + name: 'req_lower_version', + id: 'req:req_lower_version', + tags: [], + version: 1, + content: 'Descriptive text for req~req_lower_version~1', + provides: [1], + needs: [4], + covered: [0, 0, 0, 1, 3, 0], + uncovered: [3, 4], + covering: [8], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [0, 1], + wrongLinkTargets: ["feat:feat1[orphaned]"], + }, + { + index: 10, + type: 3, + title: 'Title req~req_higher_version~1', + name: 'req_higher_version', + id: 'req:req_higher_version', + tags: [], + version: 1, + content: 'Descriptive text for req~req_higher_version~1', + provides: [1], + needs: [4], + covered: [0, 0, 0, 1, 3, 0], + uncovered: [3, 4], + covering: [8], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [0, 1], + wrongLinkTargets: ["feat:feat1:3[orphaned]"], + }, + { + index: 11, + type: 0, + title: 'Title itest~itest_wrong_type~1', + name: 'itest_wrong_type', + id: 'itest:itest_wrong_type', + tags: [], + version: 1, + content: 'Descriptive text for itest~itest_wrong_type~1', + provides: [], + needs: [4], + covered: [1, 0, 0, 0, 3, 0], + uncovered: [0, 4], + covering: [], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [2], + wrongLinkTargets: [], + }, + { + index: 12, + type: 0, + title: 'Title itest~itest_dead_type~1', + name: 'itest_dead_type', + id: 'itest:itest_dead_type', + tags: [], + version: 1, + content: 'Descriptive text for itest~itest_dead_type~1', + provides: [], + needs: [4], + covered: [1, 0, 0, 0, 3, 0], + uncovered: [0, 4], + covering: [], + coveredBy: [], + depends: [], + status: 0, + path: [], + sourceFile: '', + sourceLine: 0, + comments: '', + wrongLinkTypes: [1], + wrongLinkTargets: ["feat:feat2:3[orphaned]"], + }, + ] + } +})(window);