diff --git a/.agents/skills/openfasttrace/SKILL.md b/.agents/skills/openfasttrace/SKILL.md index 68debdbce..2e25ae04b 100644 --- a/.agents/skills/openfasttrace/SKILL.md +++ b/.agents/skills/openfasttrace/SKILL.md @@ -71,6 +71,28 @@ Multiple coverage: ## Tracing +## Syntax: Gherkin + +Gherkin `.feature` files can define OFT scenario items. Put exactly one OFT ID +in the contiguous tag region immediately before a `Scenario` or `Scenario +Outline`. Optional `# Covers:` and `# Needs:` comments belong between the tags +and the scenario header. Multiple `Covers` comments accumulate IDs; `Needs` +may appear once. + +```gherkin +@id:scn~user-login~1 +# Covers: req~authentication~1 +# Needs: dsn, itest +Scenario: User logs in + Given a registered user + When valid credentials are entered + Then access is granted +``` + +Legacy coverage tags are recognized only in Gherkin comments, for example +`# [impl~login~1 -> dsn~authentication~1]`. Executable Gherkin lines are not +evaluated for coverage tags. + Tracing can be performed via CLI, Maven, or Gradle. ### CLI Usage diff --git a/doc/changes/changes.md b/doc/changes/changes.md index e12760c6d..a3891fc6e 100644 --- a/doc/changes/changes.md +++ b/doc/changes/changes.md @@ -1,5 +1,6 @@ # Changes +* [4.7.0](changes_4.7.0.md) * [4.6.0](changes_4.6.0.md) * [4.5.0](changes_4.5.0.md) * [4.4.0](changes_4.4.0.md) diff --git a/doc/changes/changes_4.7.0.md b/doc/changes/changes_4.7.0.md new file mode 100644 index 000000000..22c995bca --- /dev/null +++ b/doc/changes/changes_4.7.0.md @@ -0,0 +1,14 @@ +# OpenFastTrace 4.7.0, released 2026-07-?? + +Code name: Gherkin Importer + +## Summary + +OpenFastTrace can now import traced specifications directly from Gherkin +`.feature` files. Annotate scenarios or scenario outlines with an OFT ID and +optional `Covers` and `Needs` metadata; existing coverage tags remain supported +when written in Gherkin comments. + +## New Features + +* #563: Added Gherkin `.feature` specification import for annotated scenarios and scenario outlines. diff --git a/doc/spec/design.md b/doc/spec/design.md index 48353ade3..7156b075f 100644 --- a/doc/spec/design.md +++ b/doc/spec/design.md @@ -235,8 +235,49 @@ The default priorities for standard importers are: 1. Markdown Importer: 1000 2. reStructuredText Importer: 2000 3. Specobject (ReqM2) Importer: 3000 -4. Tag Importer: 10000 -5. Zip Importer: 20000 +4. Gherkin Importer: 9000 +5. Tag Importer: 10000 +6. Zip Importer: 20000 + +### Gherkin Import + +#### Importer Selection +`dsn~gherkin.importer-selection~1` + +The Gherkin importer selects `.feature` files with priority 9000, ahead of the Tag Importer. This gives Gherkin syntax ownership to the dedicated importer while retaining the Tag Importer as the fallback for other source files. + +Covers: + +* `req~gherkin-scenario-import~1` + +Needs: impl, utest, itest + +#### Streaming Import +`dsn~gherkin.streaming-import~1` + +The Gherkin importer scans each input file once. It imports only scenarios and scenario outlines with exactly one immediately preceding `@id:` tag, maps header location and title to import events, and streams non-comment scenario steps into the description until a Gherkin block boundary. + +Scoped `# Covers:` and `# Needs:` comments are validated between the tag region and header. Multiple `Covers` directives accumulate coverage IDs; `Needs` occurs at most once. The importer retains only active metadata and previously imported IDs. + +Covers: + +* `req~gherkin-scenario-import~1` +* `req~gherkin-metadata-validation~1` + +Needs: impl, utest + +#### Support Legacy Coverage Tags +`dsn~gherkin.comment-coverage-tags~1` + +The Gherkin importer delegates only comment lines to the shared coverage-tag parser. This preserves legacy comment coverage tags without applying their regular expressions to executable Gherkin text. + +When a comment tag occurs inside an imported scenario, its listener events are buffered until the scenario ends. The shared parser emits complete specification-item event sequences, so emitting them immediately would interleave them with the open scenario item and corrupt the listener state. + +Covers: + +* `req~gherkin-comment-coverage-tags~1` + +Needs: impl, utest ### ReqM2 File Detection `dsn~import.reqm2-file-detection~1` @@ -558,7 +599,7 @@ Needs: impl, itest #### HTML Reports Allows Configuring Details Display Status `dsn~reporting.html.details-display~1` -OFT allows configuring the specification item detail section display status (expanded or collapsed). Default is collapsed. +OFT allows configuring the specification item detail section display status (expanded or collapsed). Default is collapsed. Covers: @@ -699,13 +740,13 @@ Needs: impl, utest A requirement ID has the following format requirement-id = type "~" id "~" revision - + type = 1*ALPHA - + id = id-fragment *("." id-fragment) - + id-fragment = UNICODE_ALPHA *(UNICODE_ALPHA / DIGIT / "_" / "-") - + revision = 1*DIGIT Rationale: @@ -750,9 +791,9 @@ Needs: impl, utest In Markdown specification item references have the following format: reference = (plain-reference / url-style-link) - + plain-reference = requirement-id - + url-style-link = "[" link-text "]" "(" "#" requirement-id ")" Covers: @@ -767,9 +808,9 @@ Needs: impl, utest The Markdown Importer supports the following format for links that cover a different specification item. covers-list = covers-header 1*(LINEBREAK covers-line) - + covers-header = "Covers:" *WSP - + covers-line = *WSP "*" *WSP reference Only one traced reference per line is supported. Any optional text after the reference is ignored if it is separated by at least one whitespace character @@ -790,9 +831,9 @@ Needs: impl, utest The Markdown Importer supports the following format for links to a different specification item which the current depends on. depends-list = depends-header 1*(LINEBREAK depends-line) - + depends-header = "Depends:" *WSP - + depends-line = *WSP "*" *WSP reference Only one traced reference per line is supported. Any optional text after the reference is ignored if it is separated by at least one whitespace character @@ -852,11 +893,11 @@ The Markdown Importer supports forwarding required coverage from one artifact ty artifact-need-redirection = skipped-artifact-type *WSP "-->" *WSP target-artifact-list *WSP ":" *WSP original-requirement-id - + skipped-artifact-type = artifact-type - + target-artifact-list = artifact-type *("," *WSP artifact-type) - + original-requirement-id = requirement-id The following example shows an architectural specification item that forwards the needed coverage directly to the detailed design and an integration test: @@ -1234,7 +1275,7 @@ Needs: impl, utest ### Why is This Architecture Relevant? -Authors of importers need to be able to rely on these cleanups being done centrally, so that they don't have to implement them themselves. +Authors of importers need to be able to rely on these cleanups being done centrally, so that they don't have to implement them themselves. ### Alternatives Considered diff --git a/doc/spec/design/quality_requirements.md b/doc/spec/design/quality_requirements.md index 04101bacf..3d94811a3 100644 --- a/doc/spec/design/quality_requirements.md +++ b/doc/spec/design/quality_requirements.md @@ -115,11 +115,12 @@ The following rules are written for both human contributors and coding agents. T ### Java Test Rules -1. Use Hamcrest matchers for assertions. -2. Extract repeated complex assertions into dedicated matcher classes when that improves readability. -3. Declare only the specific checked exceptions that are directly thrown by the code under test. Do not use generic exceptions (e.g., Exception, Throwable) in test method signatures. -4. When asserting exceptions, the assertion should invoke exactly one method call—the method under test. Avoid nesting additional method calls inside the assertion. Prepare all inputs outside the assertion so the failure is attributable to a single call. -5. Prefer @ParameterizedTest for testing multiple input variations instead of generating or mutating test data within a single test method (e.g., loops or inline variations). +1. Test method names must start with `test` and describe the behavior under test. +2. Use Hamcrest matchers for assertions. +3. Extract repeated complex assertions into dedicated matcher classes when that improves readability. +4. Declare only the specific checked exceptions that are directly thrown by the code under test. Do not use generic exceptions (e.g., Exception, Throwable) in test method signatures. +5. When asserting exceptions, the assertion should invoke exactly one method call—the method under test. Avoid nesting additional method calls inside the assertion. Prepare all inputs outside the assertion so the failure is attributable to a single call. +6. Prefer @ParameterizedTest for testing multiple input variations instead of generating or mutating test data within a single test method (e.g., loops or inline variations). ## Dependency Policy diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index c4efb2b41..9ec3f6ab7 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -125,6 +125,46 @@ Coverage tags indicate parts of the source code that implements a certain requir Needs: req +### Gherkin Import +`feat~gherkin-import~1` + +OFT imports specification items from annotated Gherkin scenarios and scenario outlines in `.feature` files. + +Needs: req + +#### Import Gherkin Scenarios +`req~gherkin-scenario-import~1` + +OFT imports a Gherkin `Scenario` or `Scenario Outline` as a specification item when its immediately preceding contiguous tag region contains exactly one `@id:` tag. The scenario header supplies the title and location; scenario steps form the description. + +Covers: + +* [feat~gherkin-import~1](#gherkin-import) + +Needs: dsn + +#### Validate Gherkin Metadata +`req~gherkin-metadata-validation~1` + +OFT accepts optional, scoped `# Covers:` and `# Needs:` comments between an ID tag region and its scenario header. `Covers` may occur multiple times; `Needs` may occur once. Each directive must contain a non-empty, valid, duplicate-free list. Invalid IDs, artifact types, repeated IDs, duplicate scenario IDs, and orphan directives cause an import error that identifies the file and line. + +Covers: + +* [feat~gherkin-import~1](#gherkin-import) + +Needs: dsn + +#### Preserve Gherkin Comment Coverage Tags +`req~gherkin-comment-coverage-tags~1` + +OFT imports legacy coverage tags from comments in `.feature` files, but does not evaluate coverage tags in executable Gherkin lines. + +Covers: + +* [feat~gherkin-import~1](#gherkin-import) + +Needs: dsn + ### ReqM2 Export `feat~reqm2-export~1` @@ -526,7 +566,7 @@ Covers: Needs: dsn -#### Include Items That Don't Have Tags Or Where at Least One Tag Matches +#### Include Items That Don't Have Tags Or Where at Least One Tag Matches `req~include-items-that-do-not-have-tags-or-where-at-least-one-tag-matches~1` OFT gives users the option to include only specification items that either do not have tags or have at least one tag from a configurable set of tags during processing. @@ -548,7 +588,7 @@ Reports are the main way to find out if a projects requirements are covered prop Users can choose to display the requirement origin (e.g. file and line number) in reports: * In the body of a specification item -* For each link to a specification item +* For each link to a specification item Rationale: @@ -806,29 +846,29 @@ Covers: Needs: dsn #### Common - + ##### CLI Help `req~cli.help~1` - + `help`, `-h` and `--help` show a short help text with command line usage. - + Covers: - + * [feat~command-line-interface~1](#command-line-interface) - + Needs: dsn - + ##### CLI Version `req~cli.version~1` - + `help`, `-h` and `--help` show the version of OFT. - + Covers: - + * [feat~command-line-interface~1](#command-line-interface) - + Needs: dsn - + ##### Input Selection `req~cli.input-selection~1` diff --git a/doc/user_guide.md b/doc/user_guide.md index 15daae3d9..70b29d02c 100644 --- a/doc/user_guide.md +++ b/doc/user_guide.md @@ -822,6 +822,25 @@ Note that XML is at the moment not yet supported by the Tag Importer, because it **Test Specification languages** * [Gherkin](https://cucumber.io/docs/gherkin/) (`.feature`) + +#### Gherkin + +OFT imports Gherkin `Scenario` and `Scenario Outline` blocks in `.feature` files when the immediately preceding tag region contains one OFT ID tag. Place optional `Covers` and `Needs` comments after the tags and before the scenario header: + +```gherkin +@smoke +@id:scn~user-can-log-in~1 +# Covers: req~authentication~1 +# Needs: dsn, itest +Scenario: A registered user logs in + Given a registered user + When they enter valid credentials + Then access is granted +``` + +The scenario header becomes the item title and location; its executable steps become the description. `Covers` and `Needs` are case-sensitive and optional. Multiple `Covers` comments accumulate coverage IDs, while `Needs` may appear once; all lists must be non-empty and comma-separated. Invalid or duplicate IDs, types, or directives cause an import error. + +Existing full coverage tags remain supported in Gherkin comments, for example `# [impl~login~1 -> dsn~authentication~1]`. OFT deliberately ignores coverage-tag-shaped text in executable Gherkin lines. #### Markdown diff --git a/importer/gherkin/pom.xml b/importer/gherkin/pom.xml new file mode 100644 index 000000000..4038d4640 --- /dev/null +++ b/importer/gherkin/pom.xml @@ -0,0 +1,31 @@ + + 4.0.0 + openfasttrace-importer-gherkin + OpenFastTrace Gherkin Importer + + ../../openfasttrace-mc-deployable-parent/pom.xml + org.itsallcode.openfasttrace + openfasttrace-mc-deployable-parent + ${revision} + + + ${reproducible.build.timestamp} + + + + org.itsallcode.openfasttrace + openfasttrace-api + + + org.itsallcode.openfasttrace + openfasttrace-importer-tag-importer-common + + + org.itsallcode.openfasttrace + openfasttrace-testutil + test + + + diff --git a/importer/gherkin/src/main/java/module-info.java b/importer/gherkin/src/main/java/module-info.java new file mode 100644 index 000000000..040409eca --- /dev/null +++ b/importer/gherkin/src/main/java/module-info.java @@ -0,0 +1,10 @@ +import org.itsallcode.openfasttrace.importer.gherkin.GherkinImporterFactory; + +/** Provides an importer for Gherkin {@code .feature} files. */ +module org.itsallcode.openfasttrace.importer.gherkin { + requires transitive org.itsallcode.openfasttrace.api; + requires org.itsallcode.openfasttrace.importer.tag.common; + + provides org.itsallcode.openfasttrace.api.importer.ImporterFactory + with GherkinImporterFactory; +} diff --git a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/EventBuffer.java b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/EventBuffer.java new file mode 100644 index 000000000..ef9d85b69 --- /dev/null +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/EventBuffer.java @@ -0,0 +1,93 @@ +package org.itsallcode.openfasttrace.importer.gherkin; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import org.itsallcode.openfasttrace.api.core.*; +import org.itsallcode.openfasttrace.api.importer.ImportEventListener; + +/** Buffers legacy coverage-tag events until the current scenario has ended. */ +final class EventBuffer implements ImportEventListener { + private final List> events = new ArrayList<>(); + + @Override + public void beginSpecificationItem() { + this.events.add(ImportEventListener::beginSpecificationItem); + } + + @Override + public void setId(final SpecificationItemId id) { + this.events.add(target -> target.setId(id)); + } + + @Override + public void setTitle(final String title) { + this.events.add(target -> target.setTitle(title)); + } + + @Override + public void setStatus(final ItemStatus status) { + this.events.add(target -> target.setStatus(status)); + } + + @Override + public void appendDescription(final String fragment) { + this.events.add(target -> target.appendDescription(fragment)); + } + + @Override + public void appendRationale(final String fragment) { + this.events.add(target -> target.appendRationale(fragment)); + } + + @Override + public void appendComment(final String fragment) { + this.events.add(target -> target.appendComment(fragment)); + } + + @Override + public void addCoveredId(final SpecificationItemId id) { + this.events.add(target -> target.addCoveredId(id)); + } + + @Override + public void addDependsOnId(final SpecificationItemId id) { + this.events.add(target -> target.addDependsOnId(id)); + } + + @Override + public void addNeededArtifactType(final String artifactType) { + this.events.add(target -> target.addNeededArtifactType(artifactType)); + } + + @Override + public void addTag(final String tag) { + this.events.add(target -> target.addTag(tag)); + } + + @Override + public void setLocation(final String path, final int line) { + this.events.add(target -> target.setLocation(path, line)); + } + + @Override + public void endSpecificationItem() { + this.events.add(ImportEventListener::endSpecificationItem); + } + + @Override + public void setLocation(final Location location) { + this.events.add(target -> target.setLocation(location)); + } + + @Override + public void setForwards(final boolean forwards) { + this.events.add(target -> target.setForwards(forwards)); + } + + void replay(final ImportEventListener listener) { + this.events.forEach(event -> event.accept(listener)); + this.events.clear(); + } +} diff --git a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporter.java b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporter.java new file mode 100644 index 000000000..ca8db6d7e --- /dev/null +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporter.java @@ -0,0 +1,23 @@ +package org.itsallcode.openfasttrace.importer.gherkin; + +import org.itsallcode.openfasttrace.api.importer.ImportEventListener; +import org.itsallcode.openfasttrace.api.importer.Importer; +import org.itsallcode.openfasttrace.api.importer.input.InputFile; +import org.itsallcode.openfasttrace.importer.tag.common.LineReader; + +/** Imports annotated Gherkin scenarios while streaming the input once. */ +// [impl->dsn~gherkin.streaming-import~1] +final class GherkinImporter implements Importer { + private final InputFile file; + private final GherkinLineConsumer lineConsumer; + + GherkinImporter(final InputFile file, final ImportEventListener listener) { + this.file = file; + this.lineConsumer = new GherkinLineConsumer(file, listener); + } + + @Override + public void runImport() { + LineReader.create(this.file).readLines(this.lineConsumer); + } +} diff --git a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterFactory.java b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterFactory.java new file mode 100644 index 000000000..f67805f4b --- /dev/null +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterFactory.java @@ -0,0 +1,23 @@ +package org.itsallcode.openfasttrace.importer.gherkin; + +import org.itsallcode.openfasttrace.api.importer.*; +import org.itsallcode.openfasttrace.api.importer.input.InputFile; + +/** Factory for importing OpenFastTrace specifications from Gherkin files. */ +// [impl->dsn~gherkin.importer-selection~1] +public class GherkinImporterFactory extends AbstractRegexMatchingImporterFactory { + /** Create a factory that accepts Gherkin {@code .feature} files. */ + public GherkinImporterFactory() { + super("(?i).*\\.feature"); + } + + @Override + public int getPriority() { + return 9000; + } + + @Override + public Importer createImporter(final InputFile file, final ImportEventListener listener) { + return new GherkinImporter(file, listener); + } +} diff --git a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java new file mode 100644 index 000000000..1b0e079df --- /dev/null +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java @@ -0,0 +1,212 @@ +package org.itsallcode.openfasttrace.importer.gherkin; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.importer.ImportEventListener; +import org.itsallcode.openfasttrace.api.importer.input.InputFile; +import org.itsallcode.openfasttrace.importer.tag.common.CoverageTagParser; +import org.itsallcode.openfasttrace.importer.tag.common.LineReader.LineConsumer; + +/** Stateful parser for the Gherkin lines of one input file. */ +// [impl->dsn~gherkin.streaming-import~1] +// [impl->dsn~gherkin.comment-coverage-tags~1] +final class GherkinLineConsumer implements LineConsumer { + private static final int UNICODE = Pattern.UNICODE_CHARACTER_CLASS; + private static final Pattern ID_TAG = Pattern.compile("@id:([^\\s]+)", UNICODE); + private static final Pattern SCENARIO = Pattern.compile("^\\s*Scenario(?: Outline)?:(.*)$", UNICODE); + private static final Pattern BOUNDARY = Pattern + .compile("^\\s*(?:Scenario(?: Outline)?|Feature|Rule|Background|Examples):", UNICODE); + private static final Pattern DIRECTIVE = Pattern.compile("^\\s*#\\s*(Covers|Needs):(.*)$", UNICODE); + private static final Pattern ARTIFACT_TYPE = Pattern.compile("\\p{IsAlphabetic}+"); + + private final InputFile file; + private final ImportEventListener listener; + private final LineConsumer coverageTagParser; + // Legacy tags emit complete items and must not interleave with an open scenario. + private final LineConsumer delayedCoverageTagParser; + private final EventBuffer delayedCoverageEvents = new EventBuffer(); + private final Set importedIds = new LinkedHashSet<>(); + private SpecificationItemId pendingId; + private Set coveredIds = new LinkedHashSet<>(); + private Set neededArtifactTypes = new LinkedHashSet<>(); + private boolean hasNeedsDirective; + private boolean metadataRegion; + private boolean tagRegion; + private boolean importingScenario; + + GherkinLineConsumer(final InputFile file, final ImportEventListener listener) { + this.file = file; + this.listener = listener; + this.coverageTagParser = CoverageTagParser.create(null, file, listener); + this.delayedCoverageTagParser = CoverageTagParser.create(null, file, this.delayedCoverageEvents); + } + + @Override + public void readLine(final int lineNumber, final String line) { + if (line.trim().startsWith("#")) { + getCoverageTagParser().readLine(lineNumber, line); + } + final Matcher scenario = SCENARIO.matcher(line); + if (scenario.matches()) { + endScenario(); + beginScenario(lineNumber, scenario.group(1).trim()); + return; + } + if (BOUNDARY.matcher(line).find()) { + endScenario(); + clearMetadata(); + return; + } + if (this.importingScenario) { + if (!line.trim().startsWith("#")) { + this.listener.appendDescription(line + System.lineSeparator()); + } + return; + } + readMetadata(lineNumber, line); + } + + @Override + public void finish() { + endScenario(); + } + + private LineConsumer getCoverageTagParser() { + return this.importingScenario ? this.delayedCoverageTagParser : this.coverageTagParser; + } + + private void readMetadata(final int lineNumber, final String line) { + if (line.trim().startsWith("@")) { + readTagRegion(lineNumber, line.trim()); + return; + } + final Matcher directive = DIRECTIVE.matcher(line); + if (this.metadataRegion && directive.matches()) { + readDirective(lineNumber, directive.group(1), directive.group(2)); + return; + } + if (!line.trim().startsWith("#")) { + clearMetadata(); + } + } + + private void readTagRegion(final int lineNumber, final String tags) { + if (!this.tagRegion) { + clearMetadata(); + } + this.metadataRegion = true; + this.tagRegion = true; + final Matcher matcher = ID_TAG.matcher(tags); + while (matcher.find()) { + if (this.pendingId != null) { + fail(lineNumber, "multiple @id tags before a scenario"); + } + this.pendingId = parseId(lineNumber, matcher.group(1)); + } + } + + private void readDirective(final int lineNumber, final String name, final String values) { + this.tagRegion = false; + if (this.pendingId == null) { + fail(lineNumber, name + " directive requires exactly one preceding @id tag"); + } + final boolean covers = "Covers".equals(name); + if (!covers && this.hasNeedsDirective) { + fail(lineNumber, "repeated " + name + " directive"); + } + final String[] entries = splitValues(lineNumber, name, values); + if (covers) { + readCoveredIds(lineNumber, entries); + return; + } + readNeededArtifactTypes(lineNumber, entries); + this.hasNeedsDirective = true; + } + + private String[] splitValues(final int lineNumber, final String name, final String values) { + if (values.trim().isEmpty()) { + fail(lineNumber, name + " directive requires a non-empty list"); + } + return values.trim().split(",", -1); + } + + private void readCoveredIds(final int lineNumber, final String[] entries) { + for (final String entry : entries) { + final String value = requireValue(lineNumber, "Covers", entry); + final SpecificationItemId id = parseId(lineNumber, value); + if (!this.coveredIds.add(id)) { + fail(lineNumber, "Covers directive contains duplicate value '" + id + "'"); + } + } + } + + private void readNeededArtifactTypes(final int lineNumber, final String[] entries) { + for (final String entry : entries) { + final String value = requireValue(lineNumber, "Needs", entry); + if (!ARTIFACT_TYPE.matcher(value).matches()) { + fail(lineNumber, "Needs directive contains invalid artifact type '" + value + "'"); + } + if (!this.neededArtifactTypes.add(value)) { + fail(lineNumber, "Needs directive contains duplicate value '" + value + "'"); + } + } + } + + private String requireValue(final int lineNumber, final String name, final String entry) { + final String value = entry.trim(); + if (value.isEmpty()) { + fail(lineNumber, name + " directive contains an empty value"); + } + return value; + } + + private SpecificationItemId parseId(final int lineNumber, final String value) { + if (!SpecificationItemId.ID_PATTERN.matcher(value).matches()) { + fail(lineNumber, "invalid specification item ID '" + value + "'"); + } + return SpecificationItemId.parseId(value); + } + + private void beginScenario(final int lineNumber, final String title) { + if (this.pendingId == null) { + clearMetadata(); + return; + } + if (!this.importedIds.add(this.pendingId)) { + fail(lineNumber, "duplicate Gherkin ID '" + this.pendingId + "'"); + } + this.listener.beginSpecificationItem(); + this.listener.setLocation(this.file.getPath(), lineNumber); + this.listener.setId(this.pendingId); + this.listener.setTitle(title); + this.coveredIds.forEach(this.listener::addCoveredId); + this.neededArtifactTypes.forEach(this.listener::addNeededArtifactType); + this.importingScenario = true; + clearMetadata(); + } + + private void endScenario() { + if (this.importingScenario) { + this.listener.endSpecificationItem(); + this.importingScenario = false; + this.delayedCoverageEvents.replay(this.listener); + } + } + + private void clearMetadata() { + this.pendingId = null; + this.coveredIds = new LinkedHashSet<>(); + this.neededArtifactTypes = new LinkedHashSet<>(); + this.hasNeedsDirective = false; + this.metadataRegion = false; + this.tagRegion = false; + } + + private void fail(final int lineNumber, final String reason) { + throw new IllegalArgumentException(this.file.getPath() + ":" + lineNumber + ": " + reason); + } +} diff --git a/importer/gherkin/src/main/resources/META-INF/services/org.itsallcode.openfasttrace.api.importer.ImporterFactory b/importer/gherkin/src/main/resources/META-INF/services/org.itsallcode.openfasttrace.api.importer.ImporterFactory new file mode 100644 index 000000000..77df55442 --- /dev/null +++ b/importer/gherkin/src/main/resources/META-INF/services/org.itsallcode.openfasttrace.api.importer.ImporterFactory @@ -0,0 +1 @@ +org.itsallcode.openfasttrace.importer.gherkin.GherkinImporterFactory diff --git a/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java new file mode 100644 index 000000000..fbd2bd8ab --- /dev/null +++ b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java @@ -0,0 +1,237 @@ +package org.itsallcode.openfasttrace.importer.gherkin; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +import java.io.BufferedReader; +import java.io.StringReader; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import org.itsallcode.openfasttrace.api.core.SpecificationItem; +import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.importer.ImportEventListener; +import org.itsallcode.openfasttrace.api.importer.ImporterException; +import org.itsallcode.openfasttrace.api.importer.input.InputFile; +import org.itsallcode.openfasttrace.testutil.importer.ImportAssertions; +import org.itsallcode.openfasttrace.testutil.importer.input.StreamInput; +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 org.mockito.InOrder; + +class GherkinImporterTest { + private static final GherkinImporterFactory FACTORY = new GherkinImporterFactory(); + + // [utest->dsn~gherkin.streaming-import~1] + @Test + void testImportsScenarioOutlineWithScopedMetadataAndSteps() { + final String source = """ + @smoke + @id:scn~account-login~1 + # Covers: req~login~1 + # Needs: dsn, itest + Scenario Outline: Login works + Given a registered user + When they log in + Then access is granted + Examples: + | user | + | Ada | + """; + + final List items = importText(source); + + assertThat(items, contains( + hasProperty("id", hasToString("scn~account-login~1")))); + final SpecificationItem item = items.get(0); + assertThat(item.getTitle(), is("Login works")); + assertThat(item.getLocation().getLine(), is(5)); + assertThat(item.getDescription(), is(String.join(System.lineSeparator(), + "Given a registered user", " When they log in", " Then access is granted"))); + assertThat(item.getCoveredIds(), contains(hasToString("req~login~1"))); + assertThat(item.getNeedsArtifactTypes(), containsInAnyOrder("dsn", "itest")); + } + + // [utest->dsn~gherkin.importer-selection~1] + @Test + void testFactorySupportsFeatureFilesWithHigherPrecedenceThanTagImporter() { + final InputFile file = StreamInput.forReader(Path.of("specification.feature"), + new java.io.BufferedReader(new java.io.StringReader(""))); + + assertThat(FACTORY.supportsFile(file), is(true)); + assertThat(FACTORY.getPriority(), is(9000)); + } + + // [utest->dsn~gherkin.streaming-import~1] + @Test + void testIgnoresScenarioWithoutOftMetadata() { + final List items = importText(""" + Feature: login + Scenario: ordinary scenario + Given nothing + """); + + assertThat(items, is(empty())); + } + + // [utest->dsn~gherkin.streaming-import~1] + @Test + void testImportsMultipleCoversDirectives() { + final String source = """ + @id:scn~account-login~1 + # Covers: req~login~1 + # Covers: req~security~1 + Scenario: Login + """; + + final List items = importText(source); + + assertThat(items.get(0).getCoveredIds(), contains( + hasToString("req~login~1"), hasToString("req~security~1"))); + } + + // [utest->dsn~gherkin.streaming-import~1] + @Test + void testRejectsDirectiveWithoutId() { + final String source = """ + @ordinary + # Needs: dsn + Scenario: Login + """; + + final ImporterException exception = assertThrows(ImporterException.class, () -> importText(source)); + + assertThat(exception.getMessage(), hasToString(org.hamcrest.Matchers.containsString("requires exactly one"))); + } + + // [utest->dsn~gherkin.streaming-import~1] + @Test + void testIgnoresDirectivesOutsideAnIdMetadataRegion() { + final List items = importText(""" + # Covers: req~login~1 + Scenario: Login + """); + + assertThat(items, is(empty())); + } + + // [utest->dsn~gherkin.streaming-import~1] + @Test + void testKeepsMetadataWhenAnUnrelatedCommentPrecedesTheScenario() { + final List items = importText(""" + @id:scn~login~1 + # A human-readable comment + Scenario: Login + """); + + assertThat(items, contains(hasProperty("id", hasToString("scn~login~1")))); + } + + // [utest->dsn~gherkin.streaming-import~1] + @ParameterizedTest + @MethodSource("invalidMetadata") + void testRejectsInvalidMetadata(final String source, final String reason) { + final ImporterException exception = assertThrows(ImporterException.class, () -> importText(source)); + + assertThat(exception.getMessage(), containsString(reason)); + } + + private static Stream invalidMetadata() { + return Stream.of( + Arguments.of(""" + @id:invalid + Scenario: Login + """, "invalid specification item ID"), + Arguments.of(""" + @id:scn~login~1 + @id:scn~another-login~1 + Scenario: Login + """, "multiple @id tags"), + Arguments.of(""" + @id:scn~login~1 + # Needs: dsn + # Needs: itest + Scenario: Login + """, "repeated Needs directive"), + Arguments.of(""" + @id:scn~login~1 + # Covers: + Scenario: Login + """, "requires a non-empty list"), + Arguments.of(""" + @id:scn~login~1 + # Covers: req~login~1, + Scenario: Login + """, "contains an empty value"), + Arguments.of(""" + @id:scn~login~1 + # Covers: req~login~1, req~login~1 + Scenario: Login + """, "contains duplicate value"), + Arguments.of(""" + @id:scn~login~1 + # Needs: invalid-type + Scenario: Login + """, "invalid artifact type"), + Arguments.of(""" + @id:scn~login~1 + # Needs: dsn, dsn + Scenario: Login + """, "contains duplicate value"), + Arguments.of(""" + @id:scn~login~1 + Scenario: Login + Feature: Another feature + @id:scn~login~1 + Scenario: Login again + """, "duplicate Gherkin ID")); + } + + // [utest->dsn~gherkin.comment-coverage-tags~1] + @Test + void testImportsCommentCoverageTagsButIgnoresExecutableCoverageTags() { + final String source = """ + @id:scn~ordinary~1 + Scenario: ordinary + # [%s] + Given [%s] + """.formatted("impl~gherkin-comment~1 -> dsn~gherkin~1", + "impl~gherkin-executable~1 -> dsn~gherkin~1"); + final List items = importText(source); + + assertThat(items, containsInAnyOrder( + hasProperty("id", hasToString("scn~ordinary~1")), + hasProperty("id", hasToString("impl~gherkin-comment~1")))); + } + + // [utest->dsn~gherkin.comment-coverage-tags~1] + @Test + void testDelaysCommentCoverageTagsUntilAfterTheScenario() { + final ImportEventListener listener = mock(ImportEventListener.class); + final InputFile file = StreamInput.forReader(Path.of("specification.feature"), + new BufferedReader(new StringReader(""" + @id:scn~ordinary~1 + Scenario: ordinary + # [impl~gherkin-comment~1 -> dsn~gherkin~1] + Given a step + """))); + + new GherkinImporter(file, listener).runImport(); + + final InOrder events = inOrder(listener); + events.verify(listener).beginSpecificationItem(); + events.verify(listener).setId(SpecificationItemId.parseId("scn~ordinary~1")); + events.verify(listener).endSpecificationItem(); + events.verify(listener).beginSpecificationItem(); + } + + private static List importText(final String source) { + return ImportAssertions.runImporterOnText(Path.of("specification.feature"), source, FACTORY); + } +} diff --git a/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/TestEventBuffer.java b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/TestEventBuffer.java new file mode 100644 index 000000000..3e0338fb9 --- /dev/null +++ b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/TestEventBuffer.java @@ -0,0 +1,106 @@ +package org.itsallcode.openfasttrace.importer.gherkin; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.function.Consumer; + +import org.itsallcode.openfasttrace.api.core.*; +import org.itsallcode.openfasttrace.api.importer.ImportEventListener; +import org.junit.jupiter.api.Test; + +class TestEventBuffer { + @Test + void testReplaysBeginSpecificationItem() { + assertEvent(EventBuffer::beginSpecificationItem, ImportEventListener::beginSpecificationItem); + } + + @Test + void testReplaysId() { + final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); + assertEvent(buffer -> buffer.setId(id), listener -> verify(listener).setId(id)); + } + + @Test + void testReplaysTitle() { + assertEvent(buffer -> buffer.setTitle("title"), listener -> verify(listener).setTitle("title")); + } + + @Test + void testReplaysStatus() { + assertEvent(buffer -> buffer.setStatus(ItemStatus.DRAFT), + listener -> verify(listener).setStatus(ItemStatus.DRAFT)); + } + + @Test + void testReplaysDescription() { + assertEvent(buffer -> buffer.appendDescription("description"), + listener -> verify(listener).appendDescription("description")); + } + + @Test + void testReplaysRationale() { + assertEvent(buffer -> buffer.appendRationale("rationale"), + listener -> verify(listener).appendRationale("rationale")); + } + + @Test + void testReplaysComment() { + assertEvent(buffer -> buffer.appendComment("comment"), + listener -> verify(listener).appendComment("comment")); + } + + @Test + void testReplaysCoveredId() { + final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); + assertEvent(buffer -> buffer.addCoveredId(id), listener -> verify(listener).addCoveredId(id)); + } + + @Test + void testReplaysDependencyId() { + final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); + assertEvent(buffer -> buffer.addDependsOnId(id), listener -> verify(listener).addDependsOnId(id)); + } + + @Test + void testReplaysNeededArtifactType() { + assertEvent(buffer -> buffer.addNeededArtifactType("dsn"), + listener -> verify(listener).addNeededArtifactType("dsn")); + } + + @Test + void testReplaysTag() { + assertEvent(buffer -> buffer.addTag("tag"), listener -> verify(listener).addTag("tag")); + } + + @Test + void testReplaysPathLocation() { + assertEvent(buffer -> buffer.setLocation("file.feature", 1), + listener -> verify(listener).setLocation("file.feature", 1)); + } + + @Test + void testReplaysLocation() { + final Location location = Location.create("file.feature", 2); + assertEvent(buffer -> buffer.setLocation(location), listener -> verify(listener).setLocation(location)); + } + + @Test + void testReplaysForwards() { + assertEvent(buffer -> buffer.setForwards(true), listener -> verify(listener).setForwards(true)); + } + + @Test + void testReplaysEndSpecificationItem() { + assertEvent(EventBuffer::endSpecificationItem, ImportEventListener::endSpecificationItem); + } + + private static void assertEvent(final Consumer addEvent, + final Consumer verifyEvent) { + final EventBuffer buffer = new EventBuffer(); + final ImportEventListener listener = mock(ImportEventListener.class); + addEvent.accept(buffer); + buffer.replay(listener); + verifyEvent.accept(listener); + } +} diff --git a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/DelegatingLineConsumer.java b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/DelegatingLineConsumer.java index 0def4d7ce..6e2602154 100644 --- a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/DelegatingLineConsumer.java +++ b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/DelegatingLineConsumer.java @@ -19,4 +19,10 @@ public void readLine(final int lineNumber, final String line) { this.delegates.forEach(delegate -> delegate.readLine(lineNumber, line)); } + + @Override + public void finish() + { + this.delegates.forEach(LineConsumer::finish); + } } diff --git a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LineReader.java b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LineReader.java index 32bafc053..b7d10eb67 100644 --- a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LineReader.java +++ b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LineReader.java @@ -44,6 +44,7 @@ public void readLines(final LineConsumer consumer) { currentLineNumber = reader.getLineNumber(); processLine(consumer, currentLineNumber, line); } + finish(consumer); } catch (final IOException exception) { throw new ImporterException("Error reading \"" + this.file + "\" at line " + currentLineNumber, exception); } @@ -59,6 +60,15 @@ private void processLine(final LineConsumer consumer, final int currentLineNumbe } } + private void finish(final LineConsumer consumer) { + try { + consumer.finish(); + } catch (final RuntimeException exception) { + throw new ImporterException("Error finishing " + this.file.getPath() + ": " + exception, + exception); + } + } + /** * Receives a line read from an input file. */ @@ -73,5 +83,12 @@ public interface LineConsumer { * line content without its line separator */ void readLine(int lineNumber, String line); + + /** + * Finish consuming the input after its last line has been read. + */ + default void finish() { + // Default implementation intentionally does nothing. + } } } diff --git a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestDelegatingLineConsumer.java b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestDelegatingLineConsumer.java new file mode 100644 index 000000000..2ced642f1 --- /dev/null +++ b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestDelegatingLineConsumer.java @@ -0,0 +1,35 @@ +package org.itsallcode.openfasttrace.importer.tag.common; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.itsallcode.openfasttrace.importer.tag.common.LineReader.LineConsumer; +import org.junit.jupiter.api.Test; + +class TestDelegatingLineConsumer { + @Test + void testReadLineCallsAllDelegates() { + final LineConsumer firstDelegate = mock(LineConsumer.class); + final LineConsumer secondDelegate = mock(LineConsumer.class); + final DelegatingLineConsumer consumer = new DelegatingLineConsumer(List.of(firstDelegate, secondDelegate)); + + consumer.readLine(2, "line"); + + verify(firstDelegate).readLine(2, "line"); + verify(secondDelegate).readLine(2, "line"); + } + + @Test + void testFinishesAllDelegates() { + final LineConsumer firstDelegate = mock(LineConsumer.class); + final LineConsumer secondDelegate = mock(LineConsumer.class); + final DelegatingLineConsumer consumer = new DelegatingLineConsumer(List.of(firstDelegate, secondDelegate)); + + consumer.finish(); + + verify(firstDelegate).finish(); + verify(secondDelegate).finish(); + } +} diff --git a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLineReader.java b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLineReader.java index 1da851f22..60f23ae8c 100644 --- a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLineReader.java +++ b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLineReader.java @@ -99,6 +99,16 @@ void testWrapsConsumerFailureWithLineInformation() { assertThat(exception.getMessage(), equalTo("Error processing line dummy:1 'line1': " + cause)); } + @Test + void testWrapsConsumerFinishFailure() { + final RuntimeException cause = new IllegalArgumentException("cannot finish"); + doThrow(cause).when(this.consumerMock).finish(); + + final ImporterException exception = assertThrows(ImporterException.class, () -> readContent("")); + + assertThat(exception.getMessage(), equalTo("Error finishing dummy: " + cause)); + } + @Test void testWrapsReaderCreationFailure() throws IOException { final InputFile file = mock(InputFile.class); @@ -126,6 +136,7 @@ private void assertLinesRead(final String... expectedLines) { inOrder.verify(this.consumerMock).readLine(lineNumber, line); lineNumber++; } + inOrder.verify(this.consumerMock).finish(); inOrder.verifyNoMoreInteractions(); } } diff --git a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java index 775694084..7d2585665 100644 --- a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java +++ b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java @@ -21,7 +21,6 @@ public class TagImporterFactory extends AbstractImporterFactory "dox", // Doxygen "c#", "cs", // C# "cfg", "conf", "ini", // configuration files - "feature", // Gherkin feature files "go", // Go "groovy", // Groovy "json", "htm", "html", "xhtml", "xml", "yaml", "yml", // markup languages diff --git a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterFactory.java b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterFactory.java index b25305b31..5bef2c5fd 100644 --- a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterFactory.java +++ b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterFactory.java @@ -26,7 +26,7 @@ protected List getSupportedFilenames() { return asList("file.java", "FILE.java", "file.md.java", "file.ads", "file.adb", "foo.bash", "foo.bar.bash", "foo.bat", "foo.java", "foo.c", "foo.C", "foo.c++", "foo.c#", "foo.cc", "foo.cfg", - "foo.conf", "foo.cpp", "foo.dox", "foo.cs", "foo.feature", "foo.fxml", "foo.go", + "foo.conf", "foo.cpp", "foo.dox", "foo.cs", "foo.fxml", "foo.go", "foo.groovy", "foo.h", "foo.H", "foo.hh", "foo.h++", "foo.htm", "foo.html", "foo.ini", "foo.js", "foo.kt", "foo.kts", "foo.mjs", "foo.cjs", "foo.ejs", "foo.ts", "foo.json", diff --git a/oft-self-trace.sh b/oft-self-trace.sh index d6b5b5471..a35633efb 100755 --- a/oft-self-trace.sh +++ b/oft-self-trace.sh @@ -22,6 +22,7 @@ if $oft_script trace \ "$base_dir/importer/specobject/src" \ "$base_dir/importer/zip/src" \ "$base_dir/importer/tag-importer-common/src" \ + "$base_dir/importer/gherkin/src" \ "$base_dir/importer/tag/src" \ "$base_dir/core/src/main" \ "$base_dir/core/src/test/java" \ diff --git a/parent/pom.xml b/parent/pom.xml index 2648a3eff..a4863bfe5 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -10,7 +10,7 @@ Free requirement tracking suite https://github.com/itsallcode/openfasttrace - 4.6.0 + 4.7.0 17 6.1.0-M1 6.1.1 @@ -147,6 +147,12 @@ ${revision} compile + + org.itsallcode.openfasttrace + openfasttrace-importer-gherkin + ${revision} + compile + org.itsallcode.openfasttrace openfasttrace-importer-tag-importer-common @@ -615,7 +621,7 @@ - org.eclipse.m2e diff --git a/pom.xml b/pom.xml index 3b7d3fca3..14e96d6b3 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ importer/restructuredtext importer/specobject importer/tag-importer-common + importer/gherkin importer/tag importer/zip reporter/plaintext diff --git a/product/pom.xml b/product/pom.xml index 58214bd1f..23d7f68fc 100644 --- a/product/pom.xml +++ b/product/pom.xml @@ -37,6 +37,10 @@ org.itsallcode.openfasttrace openfasttrace-importer-specobject + + org.itsallcode.openfasttrace + openfasttrace-importer-gherkin + org.itsallcode.openfasttrace openfasttrace-importer-tag diff --git a/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java b/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java index 33368f051..381bf5d63 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java @@ -69,7 +69,7 @@ private static ReporterFactoryLoader createReporterLoader() @ParameterizedTest @CsvSource( - { "md", "oreqm", "java", "zip" }) + { "md", "oreqm", "java", "feature", "zip" }) void importerAvailable(final String suffix) { final InputFile file = RealFileInput.forPath(Paths.get("file." + suffix)); 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 3144600ae..98ad3c447 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 @@ -14,6 +14,7 @@ import org.itsallcode.openfasttrace.api.report.ReporterFactory; import org.itsallcode.openfasttrace.exporter.specobject.SpecobjectExporterFactory; import org.itsallcode.openfasttrace.importer.markdown.MarkdownImporterFactory; +import org.itsallcode.openfasttrace.importer.gherkin.GherkinImporterFactory; import org.itsallcode.openfasttrace.importer.restructuredtext.RestructuredTextImporterFactory; import org.itsallcode.openfasttrace.importer.specobject.SpecobjectImporterFactory; import org.itsallcode.openfasttrace.importer.tag.TagImporterFactory; @@ -48,11 +49,12 @@ void testImporterFactoriesRegistered() final ImporterContext context = new ImporterContext(null); final List services = getRegisteredServices(ImporterFactory.class, context); - assertThat(services, hasSize(5)); + assertThat(services, hasSize(6)); assertThat(services, containsInAnyOrder( instanceOf(MarkdownImporterFactory.class), instanceOf(RestructuredTextImporterFactory.class), instanceOf(SpecobjectImporterFactory.class), + instanceOf(GherkinImporterFactory.class), instanceOf(TagImporterFactory.class), instanceOf(ZipFileImporterFactory.class))); for (final ImporterFactory importerFactory : services) diff --git a/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java b/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java index ae55fca3a..aa50f1a68 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java @@ -42,4 +42,27 @@ void testFallBackToTagImporterWhenXmlIsNotSpecObjectFile(@TempDir Path tempDir) hasProperty("id", hasToString(startsWith("impl~foobar"))) )); } + + // [itest->dsn~gherkin.importer-selection~1] + @Test + void testSelectsGherkinImporterBeforeTagImporter(@TempDir final Path tempDir) throws IOException { + final Oft oft = Oft.create(); + Files.writeString(tempDir.resolve("login.feature"), """ + @id:scn~login~1 + Scenario: Login + # [%s] + Given [%s] + """.formatted("impl~login~1 -> dsn~login~1", + "impl~must-not-be-imported~1 -> dsn~login~1")); + final ImportSettings settings = ImportSettings.builder() + .addInputs(tempDir) + .filter(FilterSettings.builder().build()) + .build(); + + final List items = oft.importItems(settings); + + assertThat(items, containsInAnyOrder( + hasProperty("id", hasToString("scn~login~1")), + hasProperty("id", hasToString("impl~login~1")))); + } }