diff --git a/README.md b/README.md index 47e92c0..5e5564d 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ ValidationResult result = webLinkProcessor.process(rawHeader); SignPostingProcessor processor = new SignPostingProcessor.Builder().build(); SignPostingResult signPostResult = processor.process(result.weblinks()); -if(signPostResult.containsIssues()){ +if(signPostResult.hasIssues()){ // Retrieve the report var report = result.report(); // Investigate the report diff --git a/assets/compass-social-preview.png b/assets/compass-social-preview.png deleted file mode 100644 index 54354a2..0000000 Binary files a/assets/compass-social-preview.png and /dev/null differ diff --git a/src/main/java/life/qbic/compass/model/SignPostingResult.java b/src/main/java/life/qbic/compass/model/SignPostingResult.java index eb98291..6063d63 100644 --- a/src/main/java/life/qbic/compass/model/SignPostingResult.java +++ b/src/main/java/life/qbic/compass/model/SignPostingResult.java @@ -1,5 +1,6 @@ package life.qbic.compass.model; +import java.util.Objects; import life.qbic.linksmith.model.WebLink; import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; @@ -51,6 +52,13 @@ *
+ * {@link #level2LinksetView()} may be {@code null}. This is intentional: not every validation step + * constructs a Level 2 interpretation. Use {@link #hasLinkSetView()} to check for presence, or wrap it + * using {@code Optional.ofNullable(result.level2LinksetView())}. + *
+ * * @param signPostingView a read-only view on the validated weblinks * @param issueReport an aggregated report of all recoded issues during validation * @param level2LinksetView a Signposting Level 2 compliant view semantics in case the validator @@ -64,6 +72,47 @@ public record SignPostingResult( IssueReport issueReport, Level2LinksetView level2LinksetView) { + public SignPostingResult { + Objects.requireNonNull(signPostingView); + Objects.requireNonNull(issueReport); + } + + /** + * Creates a result without a Level 2 Link Set view. + * + *+ * Use this factory when validation did not (or must not) produce a {@link Level2LinksetView}. + * The returned result will have {@link #level2LinksetView()} set to {@code null}. + *
+ * + * @param signPostingView a read-only view on the validated weblinks (never {@code null}) + * @param issueReport an aggregated report of all recorded issues during validation (never {@code null}) + * @return a {@code SignPostingResult} with no Level 2 view + * @since 1.0.0 + */ + public static SignPostingResult withoutLinksetView(SignPostingView signPostingView, IssueReport issueReport) { + return new SignPostingResult(signPostingView, issueReport, null); + } + + /** + * Creates a result with a non-null Level 2 Link Set view. + * + * @param signPostingView a read-only view on the validated weblinks (never {@code null}) + * @param issueReport an aggregated report of all recorded issues during validation (never {@code null}) + * @param level2LinksetView a Level 2 interpretation view (must not be {@code null}) + * @return a {@code SignPostingResult} with a Level 2 view attached + * @throws NullPointerException if {@code level2LinksetView} is {@code null} + * @since 1.0.0 + */ + public static SignPostingResult withLinksetView( + SignPostingView signPostingView, + IssueReport issueReport, + Level2LinksetView level2LinksetView + ) { + Objects.requireNonNull(level2LinksetView, "level2LinksetView"); + return new SignPostingResult(signPostingView, issueReport, level2LinksetView); + } + /** * Convenience method for aggregators or filters to check, if the current SignPosting result * contains a linkset view or not. @@ -73,4 +122,35 @@ public record SignPostingResult( public boolean hasLinkSetView() { return level2LinksetView != null; } + + /** + * Convenience method to check if any issues (warnings or errors) were recorded. + * + * @return true if the {@link #issueReport()} contains any issues + * @since 1.0.0 + */ + public boolean hasIssues() { + return !issueReport.issues().isEmpty(); + } + + /** + * Convenience method to check if any errors were recorded. + * + * @return true if the {@link #issueReport()} contains errors + * @since 1.0.0 + */ + public boolean hasErrors() { + return issueReport.hasErrors(); + } + + /** + * Convenience method to check if any warnings were recorded. + * + * @return true if the {@link #issueReport()} contains warnings + * @since 1.0.0 + */ + public boolean hasWarnings() { + return issueReport.hasWarnings(); + } + } diff --git a/src/test/groovy/life/qbic/compass/model/SignPostingResultSpec.groovy b/src/test/groovy/life/qbic/compass/model/SignPostingResultSpec.groovy new file mode 100644 index 0000000..f0f63c6 --- /dev/null +++ b/src/test/groovy/life/qbic/compass/model/SignPostingResultSpec.groovy @@ -0,0 +1,180 @@ +package life.qbic.compass.model + +import spock.lang.Specification +import spock.lang.Unroll + +class SignPostingResultSpec extends Specification { + + @Unroll + def "stable API: method '#methodName' is available with return type '#returnType.simpleName' and params #paramTypes*.simpleName"() { + when: + def method = SignPostingResult.getMethod(methodName, paramTypes as Class[]) + + then: + method != null + method.returnType == returnType + + where: + methodName | returnType | paramTypes + "signPostingView" | SignPostingView | ([] as Class[]) + "issueReport" | life.qbic.linksmith.spi.WebLinkValidator.IssueReport | ([] as Class[]) + "level2LinksetView" | Level2LinksetView | ([] as Class[]) + "hasLinkSetView" | boolean | ([] as Class[]) + "hasIssues" | boolean | ([] as Class[]) + "hasErrors" | boolean | ([] as Class[]) + "hasWarnings" | boolean | ([] as Class[]) + "withoutLinksetView" | SignPostingResult | ([SignPostingView, life.qbic.linksmith.spi.WebLinkValidator.IssueReport] as Class[]) + "withLinksetView" | SignPostingResult | ([SignPostingView, life.qbic.linksmith.spi.WebLinkValidator.IssueReport, Level2LinksetView] as Class[]) + } + + def "stable API: canonical record constructor is available"() { + when: + def ctor = SignPostingResult.getDeclaredConstructor( + SignPostingView, + life.qbic.linksmith.spi.WebLinkValidator.IssueReport, + Level2LinksetView + ) + + then: + ctor != null + } + + def "stable API: record fundamental methods exist"() { + expect: + SignPostingResult.getMethod("equals", Object) != null + SignPostingResult.getMethod("hashCode") != null + SignPostingResult.getMethod("toString") != null + } + + def "behavior: hasLinkSetView returns false when level2LinksetView is null"() { + given: + def view = new SignPostingView([]) + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([]) + + and: + def result = new SignPostingResult(view, report, null) + + expect: + !result.hasLinkSetView() + } + + def "behavior: hasLinkSetView returns true when level2LinksetView is present"() { + given: + def view = new SignPostingView([]) + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([]) + + and: + def nonNullLevel2View = linkSetView() + + and: + def result = new SignPostingResult(view, report, nonNullLevel2View) + + expect: + result.hasLinkSetView() + } + + @Unroll + def "behavior: hasLinkSetView is consistent with null-check (#caseName)"(String caseName, Level2LinksetView level2View) { + given: + def view = new SignPostingView([]) + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([]) + + and: + def result = new SignPostingResult(view, report, level2View) + + expect: + result.hasLinkSetView() == (result.level2LinksetView() != null) + + where: + caseName | level2View + "null view" | null + "non-null view" | linkSetView() + } + + def "behavior: hasIssues/hasErrors/hasWarnings are all false when IssueReport is empty"() { + given: + def view = new SignPostingView([]) + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([]) + + and: + def result = new SignPostingResult(view, report, null) + + expect: + !result.hasIssues() + !result.hasErrors() + !result.hasWarnings() + } + + def "behavior: hasWarnings true implies hasIssues true (warnings are issues)"() { + given: + def view = new SignPostingView([]) + def warning = life.qbic.linksmith.spi.WebLinkValidator.Issue.warning("w1") + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([warning]) + + and: + def result = new SignPostingResult(view, report, null) + + expect: + result.hasWarnings() + !result.hasErrors() + result.hasIssues() + } + + def "behavior: hasErrors true implies hasIssues true"() { + given: + def view = new SignPostingView([]) + def error = life.qbic.linksmith.spi.WebLinkValidator.Issue.error("e1") + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([error]) + + and: + def result = new SignPostingResult(view, report, null) + + expect: + !result.hasWarnings() + result.hasErrors() + result.hasIssues() + } + + def "behavior: withoutLinksetView creates a result with null level2LinksetView"() { + given: + def view = new SignPostingView([]) + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([]) + + when: + def result = SignPostingResult.withoutLinksetView(view, report) + + then: + result.level2LinksetView() == null + !result.hasLinkSetView() + } + + def "behavior: withLinksetView rejects null level2LinksetView"() { + given: + def view = new SignPostingView([]) + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([]) + + when: + SignPostingResult.withLinksetView(view, report, null) + + then: + thrown(NullPointerException) + } + + def "behavior: withLinksetView creates a result with non-null level2LinksetView"() { + given: + def view = new SignPostingView([]) + def report = new life.qbic.linksmith.spi.WebLinkValidator.IssueReport([]) + def level2 = linkSetView() + + when: + def result = SignPostingResult.withLinksetView(view, report, level2) + + then: + result.level2LinksetView().is(level2) + result.hasLinkSetView() + } + + private static Level2LinksetView linkSetView() { + return new Level2LinksetView([], [], [], []) + } +}