From cae6f1c0b5a739b883871346964968910e672079 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 23 Jul 2026 14:00:02 +0200 Subject: [PATCH 1/7] #563: Added Gherkin `.feature` specification import --- .agents/skills/openfasttrace/SKILL.md | 22 ++ doc/changes/changes.md | 1 + doc/changes/changes_4.7.0.md | 9 + doc/spec/design.md | 75 +++-- doc/spec/design/quality_requirements.md | 11 +- doc/spec/system_requirements.md | 41 +++ doc/user_guide.md | 19 ++ importer/gherkin/pom.xml | 31 ++ .../gherkin/src/main/java/module-info.java | 10 + .../importer/gherkin/GherkinImporter.java | 276 ++++++++++++++++++ .../gherkin/GherkinImporterFactory.java | 23 ++ ...openfasttrace.api.importer.ImporterFactory | 1 + .../importer/gherkin/GherkinImporterTest.java | 116 ++++++++ .../importer/tag/TagImporterFactory.java | 1 - oft-self-trace.sh | 1 + parent/pom.xml | 10 +- pom.xml | 1 + product/pom.xml | 4 + .../TestAllServicesAvailable.java | 2 +- .../TestInitializingServiceLoader.java | 4 +- .../importer/ImporterFactoryLoaderIT.java | 22 ++ 21 files changed, 653 insertions(+), 27 deletions(-) create mode 100644 doc/changes/changes_4.7.0.md create mode 100644 importer/gherkin/pom.xml create mode 100644 importer/gherkin/src/main/java/module-info.java create mode 100644 importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporter.java create mode 100644 importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterFactory.java create mode 100644 importer/gherkin/src/main/resources/META-INF/services/org.itsallcode.openfasttrace.api.importer.ImporterFactory create mode 100644 importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java 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..0358a2274 --- /dev/null +++ b/doc/changes/changes_4.7.0.md @@ -0,0 +1,9 @@ +# OpenFastTrace 4.7.0, released 2026-07-?? + +Code name: Gherkin Importer + +## Summary + +## 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..8cc5f2b75 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -119,6 +119,47 @@ Needs: req OFT imports coverage tags from source code files. +### 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) +* [feat~coverage-tag-import~1](#coverage-tag-import) + +Needs: dsn + Rationale: Coverage tags indicate parts of the source code that implements a certain requirement. 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/GherkinImporter.java b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporter.java new file mode 100644 index 000000000..501039d01 --- /dev/null +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporter.java @@ -0,0 +1,276 @@ +package org.itsallcode.openfasttrace.importer.gherkin; + +import java.util.*; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.itsallcode.openfasttrace.api.core.*; +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.CoverageTagParser; +import org.itsallcode.openfasttrace.importer.tag.common.LineReader; +import org.itsallcode.openfasttrace.importer.tag.common.LineReader.LineConsumer; + +/** Imports annotated Gherkin scenarios while streaming the input once. */ +// [impl->dsn~gherkin.streaming-import~1] +// [impl->dsn~gherkin.comment-coverage-tags~1] +final class GherkinImporter implements Importer, LineConsumer { + private static final Pattern ID_TAG = Pattern.compile("@id:(\\S+)"); + private static final Pattern SCENARIO = Pattern.compile("^\\s*Scenario(?: Outline)?:\\s*(.*)$"); + private static final Pattern BOUNDARY = Pattern + .compile("^\\s*(?:Scenario(?: Outline)?|Feature|Rule|Background|Examples):"); + private static final Pattern DIRECTIVE = Pattern.compile("^\\s*#\\s*(Covers|Needs):(.*)$"); + + private final InputFile file; + private final ImportEventListener listener; + private final LineConsumer coverageTagParser; + // Legacy tags emit complete items and therefore must not interleave with an open scenario item. + 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; + + GherkinImporter(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 runImport() { + LineReader.create(this.file).readLines(this); + endScenario(); + } + + @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)); + 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); + } + + 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 = values.trim().split(",", -1); + if (entries.length == 0 || values.trim().isEmpty()) { + fail(lineNumber, name + " directive requires a non-empty list"); + } + for (final String entry : entries) { + if (entry.trim().isEmpty()) { + fail(lineNumber, name + " directive contains an empty value"); + } + if (covers) { + final SpecificationItemId id = parseId(lineNumber, entry.trim()); + if (!this.coveredIds.add(id)) { + fail(lineNumber, "Covers directive contains duplicate value '" + id + "'"); + } + } else if (!entry.trim().matches("\\p{Alpha}+")) { + fail(lineNumber, "Needs directive contains invalid artifact type '" + entry.trim() + "'"); + } else if (!this.neededArtifactTypes.add(entry.trim())) { + fail(lineNumber, "Needs directive contains duplicate value '" + entry.trim() + "'"); + } + } + if (!covers) { + this.hasNeedsDirective = true; + } + } + + 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); + } + + /** Buffers legacy coverage-tag events until the current scenario has ended. */ + private static 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(listener -> listener.setId(id)); + } + + @Override + public void setTitle(final String title) { + this.events.add(listener -> listener.setTitle(title)); + } + + @Override + public void setStatus(final ItemStatus status) { + this.events.add(listener -> listener.setStatus(status)); + } + + @Override + public void appendDescription(final String fragment) { + this.events.add(listener -> listener.appendDescription(fragment)); + } + + @Override + public void appendRationale(final String fragment) { + this.events.add(listener -> listener.appendRationale(fragment)); + } + + @Override + public void appendComment(final String fragment) { + this.events.add(listener -> listener.appendComment(fragment)); + } + + @Override + public void addCoveredId(final SpecificationItemId id) { + this.events.add(listener -> listener.addCoveredId(id)); + } + + @Override + public void addDependsOnId(final SpecificationItemId id) { + this.events.add(listener -> listener.addDependsOnId(id)); + } + + @Override + public void addNeededArtifactType(final String artifactType) { + this.events.add(listener -> listener.addNeededArtifactType(artifactType)); + } + + @Override + public void addTag(final String tag) { + this.events.add(listener -> listener.addTag(tag)); + } + + @Override + public void setLocation(final String path, final int line) { + this.events.add(listener -> listener.setLocation(path, line)); + } + + @Override + public void endSpecificationItem() { + this.events.add(ImportEventListener::endSpecificationItem); + } + + @Override + public void setLocation(final Location location) { + this.events.add(listener -> listener.setLocation(location)); + } + + @Override + public void setForwards(final boolean forwards) { + this.events.add(listener -> listener.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/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/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..c786aae73 --- /dev/null +++ b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java @@ -0,0 +1,116 @@ +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 java.nio.file.Path; +import java.util.List; + +import org.itsallcode.openfasttrace.api.core.SpecificationItem; +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; + +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("Given a registered user\n When they log in\n 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.metadata-validation~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.metadata-validation~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.comment-coverage-tags~1] + @Test + void testImportsCommentCoverageTagsButIgnoresExecutableCoverageTags() { + final List items = importText(""" + # [impl~gherkin-comment~1 -> dsn~gherkin~1] + Scenario: ordinary + Given [impl~gherkin-executable~1 -> dsn~gherkin~1] + """); + + assertThat(items, contains(hasProperty("id", hasToString("impl~gherkin-comment~1")))); + } + + private static List importText(final String source) { + return ImportAssertions.runImporterOnText(Path.of("specification.feature"), source, FACTORY); + } +} 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/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..926a70356 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,26 @@ 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 + # [impl~login~1 -> dsn~login~1] + Given [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")))); + } } From 49394ccaa6d6fd55cb478946ef371b9ecc9c976b Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 23 Jul 2026 14:11:22 +0200 Subject: [PATCH 2/7] Fix self-trace and tests --- doc/spec/system_requirements.md | 39 +++++++++---------- .../importer/gherkin/GherkinImporterTest.java | 12 +++--- .../importer/tag/TestTagImporterFactory.java | 2 +- .../importer/ImporterFactoryLoaderIT.java | 5 +-- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index 8cc5f2b75..9ec3f6ab7 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -119,6 +119,12 @@ Needs: req OFT imports coverage tags from source code files. +Rationale: + +Coverage tags indicate parts of the source code that implements a certain requirement. + +Needs: req + ### Gherkin Import `feat~gherkin-import~1` @@ -156,16 +162,9 @@ OFT imports legacy coverage tags from comments in `.feature` files, but does not Covers: * [feat~gherkin-import~1](#gherkin-import) -* [feat~coverage-tag-import~1](#coverage-tag-import) Needs: dsn -Rationale: - -Coverage tags indicate parts of the source code that implements a certain requirement. - -Needs: req - ### ReqM2 Export `feat~reqm2-export~1` @@ -567,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. @@ -589,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: @@ -847,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/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 index c786aae73..dda6c3cdd 100644 --- 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 @@ -68,7 +68,7 @@ void testIgnoresScenarioWithoutOftMetadata() { assertThat(items, is(empty())); } - // [utest->dsn~gherkin.metadata-validation~1] + // [utest->dsn~gherkin.streaming-import~1] @Test void testImportsMultipleCoversDirectives() { final String source = """ @@ -84,7 +84,7 @@ void testImportsMultipleCoversDirectives() { hasToString("req~login~1"), hasToString("req~security~1"))); } - // [utest->dsn~gherkin.metadata-validation~1] + // [utest->dsn~gherkin.streaming-import~1] @Test void testRejectsDirectiveWithoutId() { final String source = """ @@ -101,11 +101,11 @@ void testRejectsDirectiveWithoutId() { // [utest->dsn~gherkin.comment-coverage-tags~1] @Test void testImportsCommentCoverageTagsButIgnoresExecutableCoverageTags() { - final List items = importText(""" - # [impl~gherkin-comment~1 -> dsn~gherkin~1] + final String source = """ + # [""" + "impl~gherkin-comment~1 -> dsn~gherkin~1]\n" + """ Scenario: ordinary - Given [impl~gherkin-executable~1 -> dsn~gherkin~1] - """); + Given [""" + "impl~gherkin-executable~1 -> dsn~gherkin~1]\n"; + final List items = importText(source); assertThat(items, contains(hasProperty("id", hasToString("impl~gherkin-comment~1")))); } 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/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java b/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java index 926a70356..11bf4c19c 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java @@ -50,9 +50,8 @@ void testSelectsGherkinImporterBeforeTagImporter(@TempDir final Path tempDir) th Files.writeString(tempDir.resolve("login.feature"), """ @id:scn~login~1 Scenario: Login - # [impl~login~1 -> dsn~login~1] - Given [impl~must-not-be-imported~1 -> dsn~login~1] - """); + # [""" + "impl~login~1 -> dsn~login~1]\n" + """ + Given [""" + "impl~must-not-be-imported~1 -> dsn~login~1]\n"); final ImportSettings settings = ImportSettings.builder() .addInputs(tempDir) .filter(FilterSettings.builder().build()) From 4dbb227f49f1510329fd321417451fa3583d4ca5 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 23 Jul 2026 14:46:08 +0200 Subject: [PATCH 3/7] Fix sonar findings --- .../importer/gherkin/GherkinImporter.java | 95 +++++++----- .../importer/gherkin/GherkinImporterTest.java | 144 +++++++++++++++++- .../importer/ImporterFactoryLoaderIT.java | 6 +- 3 files changed, 204 insertions(+), 41 deletions(-) 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 index 501039d01..23eac4119 100644 --- 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 @@ -17,16 +17,19 @@ // [impl->dsn~gherkin.streaming-import~1] // [impl->dsn~gherkin.comment-coverage-tags~1] final class GherkinImporter implements Importer, LineConsumer { - private static final Pattern ID_TAG = Pattern.compile("@id:(\\S+)"); - private static final Pattern SCENARIO = Pattern.compile("^\\s*Scenario(?: Outline)?:\\s*(.*)$"); + 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):"); - private static final Pattern DIRECTIVE = Pattern.compile("^\\s*#\\s*(Covers|Needs):(.*)$"); + .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 therefore must not interleave with an open scenario item. + // Legacy tags emit complete items and therefore must not interleave with an + // open scenario item. private final LineConsumer delayedCoverageTagParser; private final EventBuffer delayedCoverageEvents = new EventBuffer(); private final Set importedIds = new LinkedHashSet<>(); @@ -59,7 +62,7 @@ public void readLine(final int lineNumber, final String line) { final Matcher scenario = SCENARIO.matcher(line); if (scenario.matches()) { endScenario(); - beginScenario(lineNumber, scenario.group(1)); + beginScenario(lineNumber, scenario.group(1).trim()); return; } if (BOUNDARY.matcher(line).find()) { @@ -119,28 +122,50 @@ private void readDirective(final int lineNumber, final String name, final String if (!covers && this.hasNeedsDirective) { fail(lineNumber, "repeated " + name + " directive"); } - final String[] entries = values.trim().split(",", -1); - if (entries.length == 0 || values.trim().isEmpty()) { + 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) { - if (entry.trim().isEmpty()) { - fail(lineNumber, name + " directive contains an empty value"); + 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 + "'"); } - if (covers) { - final SpecificationItemId id = parseId(lineNumber, entry.trim()); - if (!this.coveredIds.add(id)) { - fail(lineNumber, "Covers directive contains duplicate value '" + id + "'"); - } - } else if (!entry.trim().matches("\\p{Alpha}+")) { - fail(lineNumber, "Needs directive contains invalid artifact type '" + entry.trim() + "'"); - } else if (!this.neededArtifactTypes.add(entry.trim())) { - fail(lineNumber, "Needs directive contains duplicate value '" + entry.trim() + "'"); + } + } + + 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 + "'"); } } - if (!covers) { - this.hasNeedsDirective = true; + } + + 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) { @@ -190,7 +215,7 @@ private void fail(final int lineNumber, final String reason) { } /** Buffers legacy coverage-tag events until the current scenario has ended. */ - private static final class EventBuffer implements ImportEventListener { + static final class EventBuffer implements ImportEventListener { private final List> events = new ArrayList<>(); @Override @@ -200,57 +225,57 @@ public void beginSpecificationItem() { @Override public void setId(final SpecificationItemId id) { - this.events.add(listener -> listener.setId(id)); + this.events.add(target -> target.setId(id)); } @Override public void setTitle(final String title) { - this.events.add(listener -> listener.setTitle(title)); + this.events.add(target -> target.setTitle(title)); } @Override public void setStatus(final ItemStatus status) { - this.events.add(listener -> listener.setStatus(status)); + this.events.add(target -> target.setStatus(status)); } @Override public void appendDescription(final String fragment) { - this.events.add(listener -> listener.appendDescription(fragment)); + this.events.add(target -> target.appendDescription(fragment)); } @Override public void appendRationale(final String fragment) { - this.events.add(listener -> listener.appendRationale(fragment)); + this.events.add(target -> target.appendRationale(fragment)); } @Override public void appendComment(final String fragment) { - this.events.add(listener -> listener.appendComment(fragment)); + this.events.add(target -> target.appendComment(fragment)); } @Override public void addCoveredId(final SpecificationItemId id) { - this.events.add(listener -> listener.addCoveredId(id)); + this.events.add(target -> target.addCoveredId(id)); } @Override public void addDependsOnId(final SpecificationItemId id) { - this.events.add(listener -> listener.addDependsOnId(id)); + this.events.add(target -> target.addDependsOnId(id)); } @Override public void addNeededArtifactType(final String artifactType) { - this.events.add(listener -> listener.addNeededArtifactType(artifactType)); + this.events.add(target -> target.addNeededArtifactType(artifactType)); } @Override public void addTag(final String tag) { - this.events.add(listener -> listener.addTag(tag)); + this.events.add(target -> target.addTag(tag)); } @Override public void setLocation(final String path, final int line) { - this.events.add(listener -> listener.setLocation(path, line)); + this.events.add(target -> target.setLocation(path, line)); } @Override @@ -260,12 +285,12 @@ public void endSpecificationItem() { @Override public void setLocation(final Location location) { - this.events.add(listener -> listener.setLocation(location)); + this.events.add(target -> target.setLocation(location)); } @Override public void setForwards(final boolean forwards) { - this.events.add(listener -> listener.setForwards(forwards)); + this.events.add(target -> target.setForwards(forwards)); } void replay(final ImportEventListener listener) { 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 index dda6c3cdd..51daa2683 100644 --- 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 @@ -3,16 +3,23 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; 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.*; +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; class GherkinImporterTest { private static final GherkinImporterFactory FACTORY = new GherkinImporterFactory(); @@ -98,16 +105,145 @@ void testRejectsDirectiveWithoutId() { 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 = """ - # [""" + "impl~gherkin-comment~1 -> dsn~gherkin~1]\n" + """ + @id:scn~ordinary~1 Scenario: ordinary - Given [""" + "impl~gherkin-executable~1 -> dsn~gherkin~1]\n"; + # [%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, contains(hasProperty("id", hasToString("impl~gherkin-comment~1")))); + assertThat(items, containsInAnyOrder( + hasProperty("id", hasToString("scn~ordinary~1")), + hasProperty("id", hasToString("impl~gherkin-comment~1")))); + } + + @Test + void testReplaysAllBufferedEventTypes() { + final GherkinImporter.EventBuffer buffer = new GherkinImporter.EventBuffer(); + final ImportEventListener listener = mock(ImportEventListener.class); + final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); + final Location location = Location.create("file.feature", 2); + + buffer.beginSpecificationItem(); + buffer.setId(id); + buffer.setTitle("title"); + buffer.setStatus(ItemStatus.DRAFT); + buffer.appendDescription("description"); + buffer.appendRationale("rationale"); + buffer.appendComment("comment"); + buffer.addCoveredId(id); + buffer.addDependsOnId(id); + buffer.addNeededArtifactType("dsn"); + buffer.addTag("tag"); + buffer.setLocation("file.feature", 1); + buffer.setLocation(location); + buffer.setForwards(true); + buffer.endSpecificationItem(); + buffer.replay(listener); + + verify(listener).beginSpecificationItem(); + verify(listener).setId(id); + verify(listener).setTitle("title"); + verify(listener).setStatus(ItemStatus.DRAFT); + verify(listener).appendDescription("description"); + verify(listener).appendRationale("rationale"); + verify(listener).appendComment("comment"); + verify(listener).addCoveredId(id); + verify(listener).addDependsOnId(id); + verify(listener).addNeededArtifactType("dsn"); + verify(listener).addTag("tag"); + verify(listener).setLocation("file.feature", 1); + verify(listener).setLocation(location); + verify(listener).setForwards(true); + verify(listener).endSpecificationItem(); } private static List importText(final String source) { 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 11bf4c19c..aa50f1a68 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/importer/ImporterFactoryLoaderIT.java @@ -50,8 +50,10 @@ void testSelectsGherkinImporterBeforeTagImporter(@TempDir final Path tempDir) th Files.writeString(tempDir.resolve("login.feature"), """ @id:scn~login~1 Scenario: Login - # [""" + "impl~login~1 -> dsn~login~1]\n" + """ - Given [""" + "impl~must-not-be-imported~1 -> dsn~login~1]\n"); + # [%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()) From b9ddc6be19d7a4038c3959f836f056844204b639 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 23 Jul 2026 14:47:41 +0200 Subject: [PATCH 4/7] Fix test under windows --- .../openfasttrace/importer/gherkin/GherkinImporterTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 51daa2683..4499d3ca2 100644 --- 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 @@ -48,7 +48,8 @@ void testImportsScenarioOutlineWithScopedMetadataAndSteps() { final SpecificationItem item = items.get(0); assertThat(item.getTitle(), is("Login works")); assertThat(item.getLocation().getLine(), is(5)); - assertThat(item.getDescription(), is("Given a registered user\n When they log in\n Then access is granted")); + 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")); } From d96d46c1c4843259c4373ba1ea1a6c1931fb2756 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 23 Jul 2026 16:55:36 +0200 Subject: [PATCH 5/7] Add changelog summary --- doc/changes/changes_4.7.0.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/changes/changes_4.7.0.md b/doc/changes/changes_4.7.0.md index 0358a2274..22c995bca 100644 --- a/doc/changes/changes_4.7.0.md +++ b/doc/changes/changes_4.7.0.md @@ -4,6 +4,11 @@ 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. From 37a263e9de9c13438d356bf7f562977a6d36e930 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 23 Jul 2026 16:56:02 +0200 Subject: [PATCH 6/7] Extract classes from GherkinImporter --- .../importer/gherkin/EventBuffer.java | 93 ++++++ .../importer/gherkin/GherkinImporter.java | 287 +----------------- .../importer/gherkin/GherkinLineConsumer.java | 211 +++++++++++++ .../importer/gherkin/GherkinImporterTest.java | 58 ++-- 4 files changed, 331 insertions(+), 318 deletions(-) create mode 100644 importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/EventBuffer.java create mode 100644 importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java 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 index 23eac4119..4695fd914 100644 --- 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 @@ -1,301 +1,24 @@ package org.itsallcode.openfasttrace.importer.gherkin; -import java.util.*; -import java.util.function.Consumer; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.itsallcode.openfasttrace.api.core.*; 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.CoverageTagParser; import org.itsallcode.openfasttrace.importer.tag.common.LineReader; -import org.itsallcode.openfasttrace.importer.tag.common.LineReader.LineConsumer; /** Imports annotated Gherkin scenarios while streaming the input once. */ // [impl->dsn~gherkin.streaming-import~1] -// [impl->dsn~gherkin.comment-coverage-tags~1] -final class GherkinImporter implements Importer, 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}+"); - +final class GherkinImporter implements Importer { private final InputFile file; - private final ImportEventListener listener; - private final LineConsumer coverageTagParser; - // Legacy tags emit complete items and therefore must not interleave with an - // open scenario item. - 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; + private final GherkinLineConsumer lineConsumer; GherkinImporter(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); + this.lineConsumer = new GherkinLineConsumer(file, listener); } @Override public void runImport() { - LineReader.create(this.file).readLines(this); - endScenario(); - } - - @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); - } - - 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); - } - - /** Buffers legacy coverage-tag events until the current scenario has ended. */ - static 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(); - } + LineReader.create(this.file).readLines(this.lineConsumer); + this.lineConsumer.finish(); } } 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..82ad5d055 --- /dev/null +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java @@ -0,0 +1,211 @@ +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); + } + + 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/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java index 4499d3ca2..2666b3cbd 100644 --- 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 @@ -8,6 +8,7 @@ import java.nio.file.Path; import java.util.List; +import java.util.function.Consumer; import java.util.stream.Stream; import org.itsallcode.openfasttrace.api.core.*; @@ -207,44 +208,29 @@ void testImportsCommentCoverageTagsButIgnoresExecutableCoverageTags() { } @Test - void testReplaysAllBufferedEventTypes() { - final GherkinImporter.EventBuffer buffer = new GherkinImporter.EventBuffer(); + void testEventBufferReplaysBeginItem() { assertEvent(EventBuffer::beginSpecificationItem, ImportEventListener::beginSpecificationItem); } + @Test void testEventBufferReplaysId() { final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); assertEvent(b -> b.setId(id), l -> verify(l).setId(id)); } + @Test void testEventBufferReplaysTitle() { assertEvent(b -> b.setTitle("title"), l -> verify(l).setTitle("title")); } + @Test void testEventBufferReplaysStatus() { assertEvent(b -> b.setStatus(ItemStatus.DRAFT), l -> verify(l).setStatus(ItemStatus.DRAFT)); } + @Test void testEventBufferReplaysDescription() { assertEvent(b -> b.appendDescription("description"), l -> verify(l).appendDescription("description")); } + @Test void testEventBufferReplaysRationale() { assertEvent(b -> b.appendRationale("rationale"), l -> verify(l).appendRationale("rationale")); } + @Test void testEventBufferReplaysComment() { assertEvent(b -> b.appendComment("comment"), l -> verify(l).appendComment("comment")); } + @Test void testEventBufferReplaysCoveredId() { final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); assertEvent(b -> b.addCoveredId(id), l -> verify(l).addCoveredId(id)); } + @Test void testEventBufferReplaysDependencyId() { final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); assertEvent(b -> b.addDependsOnId(id), l -> verify(l).addDependsOnId(id)); } + @Test void testEventBufferReplaysNeededArtifactType() { assertEvent(b -> b.addNeededArtifactType("dsn"), l -> verify(l).addNeededArtifactType("dsn")); } + @Test void testEventBufferReplaysTag() { assertEvent(b -> b.addTag("tag"), l -> verify(l).addTag("tag")); } + @Test void testEventBufferReplaysPathLocation() { assertEvent(b -> b.setLocation("file.feature", 1), l -> verify(l).setLocation("file.feature", 1)); } + @Test void testEventBufferReplaysLocation() { final Location location = Location.create("file.feature", 2); assertEvent(b -> b.setLocation(location), l -> verify(l).setLocation(location)); } + @Test void testEventBufferReplaysForwards() { assertEvent(b -> b.setForwards(true), l -> verify(l).setForwards(true)); } + @Test void testEventBufferReplaysEndItem() { 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); - final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); - final Location location = Location.create("file.feature", 2); - - buffer.beginSpecificationItem(); - buffer.setId(id); - buffer.setTitle("title"); - buffer.setStatus(ItemStatus.DRAFT); - buffer.appendDescription("description"); - buffer.appendRationale("rationale"); - buffer.appendComment("comment"); - buffer.addCoveredId(id); - buffer.addDependsOnId(id); - buffer.addNeededArtifactType("dsn"); - buffer.addTag("tag"); - buffer.setLocation("file.feature", 1); - buffer.setLocation(location); - buffer.setForwards(true); - buffer.endSpecificationItem(); + addEvent.accept(buffer); buffer.replay(listener); - - verify(listener).beginSpecificationItem(); - verify(listener).setId(id); - verify(listener).setTitle("title"); - verify(listener).setStatus(ItemStatus.DRAFT); - verify(listener).appendDescription("description"); - verify(listener).appendRationale("rationale"); - verify(listener).appendComment("comment"); - verify(listener).addCoveredId(id); - verify(listener).addDependsOnId(id); - verify(listener).addNeededArtifactType("dsn"); - verify(listener).addTag("tag"); - verify(listener).setLocation("file.feature", 1); - verify(listener).setLocation(location); - verify(listener).setForwards(true); - verify(listener).endSpecificationItem(); + verifyEvent.accept(listener); } private static List importText(final String source) { From 2be32156e64150f3628b0b8d11aa246a2765e87b Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 23 Jul 2026 17:04:09 +0200 Subject: [PATCH 7/7] Add finish method to LineReader --- .../importer/gherkin/GherkinImporter.java | 1 - .../importer/gherkin/GherkinLineConsumer.java | 3 +- .../importer/gherkin/GherkinImporterTest.java | 48 ++++---- .../importer/gherkin/TestEventBuffer.java | 106 ++++++++++++++++++ .../tag/common/DelegatingLineConsumer.java | 6 + .../importer/tag/common/LineReader.java | 17 +++ .../common/TestDelegatingLineConsumer.java | 35 ++++++ .../importer/tag/common/TestLineReader.java | 11 ++ 8 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/TestEventBuffer.java create mode 100644 importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestDelegatingLineConsumer.java 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 index 4695fd914..ca8db6d7e 100644 --- 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 @@ -19,6 +19,5 @@ final class GherkinImporter implements Importer { @Override public void runImport() { LineReader.create(this.file).readLines(this.lineConsumer); - this.lineConsumer.finish(); } } 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 index 82ad5d055..1b0e079df 100644 --- 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 @@ -70,7 +70,8 @@ public void readLine(final int lineNumber, final String line) { readMetadata(lineNumber, line); } - void finish() { + @Override + public void finish() { endScenario(); } 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 index 2666b3cbd..fbd2bd8ab 100644 --- 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 @@ -3,15 +3,17 @@ 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 static org.mockito.Mockito.verify; +import java.io.BufferedReader; +import java.io.StringReader; import java.nio.file.Path; import java.util.List; -import java.util.function.Consumer; import java.util.stream.Stream; -import org.itsallcode.openfasttrace.api.core.*; +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; @@ -21,6 +23,7 @@ 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(); @@ -207,30 +210,25 @@ void testImportsCommentCoverageTagsButIgnoresExecutableCoverageTags() { hasProperty("id", hasToString("impl~gherkin-comment~1")))); } + // [utest->dsn~gherkin.comment-coverage-tags~1] @Test - void testEventBufferReplaysBeginItem() { assertEvent(EventBuffer::beginSpecificationItem, ImportEventListener::beginSpecificationItem); } - @Test void testEventBufferReplaysId() { final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); assertEvent(b -> b.setId(id), l -> verify(l).setId(id)); } - @Test void testEventBufferReplaysTitle() { assertEvent(b -> b.setTitle("title"), l -> verify(l).setTitle("title")); } - @Test void testEventBufferReplaysStatus() { assertEvent(b -> b.setStatus(ItemStatus.DRAFT), l -> verify(l).setStatus(ItemStatus.DRAFT)); } - @Test void testEventBufferReplaysDescription() { assertEvent(b -> b.appendDescription("description"), l -> verify(l).appendDescription("description")); } - @Test void testEventBufferReplaysRationale() { assertEvent(b -> b.appendRationale("rationale"), l -> verify(l).appendRationale("rationale")); } - @Test void testEventBufferReplaysComment() { assertEvent(b -> b.appendComment("comment"), l -> verify(l).appendComment("comment")); } - @Test void testEventBufferReplaysCoveredId() { final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); assertEvent(b -> b.addCoveredId(id), l -> verify(l).addCoveredId(id)); } - @Test void testEventBufferReplaysDependencyId() { final SpecificationItemId id = SpecificationItemId.parseId("req~login~1"); assertEvent(b -> b.addDependsOnId(id), l -> verify(l).addDependsOnId(id)); } - @Test void testEventBufferReplaysNeededArtifactType() { assertEvent(b -> b.addNeededArtifactType("dsn"), l -> verify(l).addNeededArtifactType("dsn")); } - @Test void testEventBufferReplaysTag() { assertEvent(b -> b.addTag("tag"), l -> verify(l).addTag("tag")); } - @Test void testEventBufferReplaysPathLocation() { assertEvent(b -> b.setLocation("file.feature", 1), l -> verify(l).setLocation("file.feature", 1)); } - @Test void testEventBufferReplaysLocation() { final Location location = Location.create("file.feature", 2); assertEvent(b -> b.setLocation(location), l -> verify(l).setLocation(location)); } - @Test void testEventBufferReplaysForwards() { assertEvent(b -> b.setForwards(true), l -> verify(l).setForwards(true)); } - @Test void testEventBufferReplaysEndItem() { assertEvent(EventBuffer::endSpecificationItem, ImportEventListener::endSpecificationItem); } - - private static void assertEvent(final Consumer addEvent, - final Consumer verifyEvent) { - final EventBuffer buffer = new EventBuffer(); + void testDelaysCommentCoverageTagsUntilAfterTheScenario() { final ImportEventListener listener = mock(ImportEventListener.class); - addEvent.accept(buffer); - buffer.replay(listener); - verifyEvent.accept(listener); + 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) { 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(); } }