From fe633be320be6c3940c06520bf75ed9bbff1e90a Mon Sep 17 00:00:00 2001 From: Christoph Fabianek Date: Thu, 8 Oct 2026 22:11:31 +0200 Subject: [PATCH] Count only active criteria in N of M, report proposed ones separately --- app/services/passport_linter.rb | 30 ++++++++++++++++++--------- app/views/home/show.html.erb | 4 +++- swagger/v1/swagger.yaml | 12 +++++++++++ test/services/passport_linter_test.rb | 20 ++++++++++++++---- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/app/services/passport_linter.rb b/app/services/passport_linter.rb index 2a7e77e..06f73a2 100644 --- a/app/services/passport_linter.rb +++ b/app/services/passport_linter.rb @@ -24,19 +24,14 @@ def run(passport:, product_id: nil, retrieval: nil, jws: nil, jws_only: false, r @jws = jws @jws_only = jws_only reports = {} - criteria = @catalogue.passport_criteria.map { |c| evaluate(c, passport, reports) } - counted = criteria.select { |c| %w[passed warning failed].include?(c[:result]) } - passed = counted.count { |c| c[:result] != "failed" } + criteria = @catalogue.passport_criteria.map do |c| + evaluate(c, passport, reports).merge(status: c["status"], counted: c["status"] == "active") + end + active, proposed = criteria.partition { |c| c[:counted] } { productId: product_id || passport&.dig("uniqueProductIdentifier"), retrieval: retrieval, - summary: { - text: "#{passed} of #{counted.size} automated checks passed", - passed: passed, - failed: counted.size - passed, - warnings: criteria.count { |c| c[:result] == "warning" }, - skipped: criteria.count { |c| c[:result] == "skipped" } - }, + summary: summary(active).merge(proposed_not_counted: summary(proposed.select { |c| c[:status] == "proposed" })), criteria: criteria, notice: "Results of automated checks only. They are no certification and establish no presumption of conformity.", "dpp-criteria": Rails.configuration.x.dpplint.criteria_ref @@ -45,6 +40,21 @@ def run(passport:, product_id: nil, retrieval: nil, jws: nil, jws_only: false, r private + # Only criteria with status active count in "N of M" (CRITERIA-FORMAT.md, + # "Results"); proposed criteria are summarised separately and not counted. + # passed and warning count as passed, skipped is not counted. + def summary(criteria) + counted = criteria.select { |c| %w[passed warning failed].include?(c[:result]) } + passed = counted.count { |c| c[:result] != "failed" } + { + text: "#{passed} of #{counted.size} automated checks passed", + passed: passed, + failed: counted.size - passed, + warnings: criteria.count { |c| c[:result] == "warning" }, + skipped: criteria.count { |c| c[:result] == "skipped" } + } + end + def evaluate(criterion, passport, reports) base = { id: criterion["id"], title: criterion["title"], level: criterion["level"] } check = criterion["check"] || {} diff --git a/app/views/home/show.html.erb b/app/views/home/show.html.erb index 8eb80a6..84fb32b 100644 --- a/app/views/home/show.html.erb +++ b/app/views/home/show.html.erb @@ -73,12 +73,14 @@ const retrieval = d.retrieval || {}; const rows = [...d.criteria].sort((a, b) => order[a.result] - order[b.result] || a.id.localeCompare(b.id)).map((c) => { const notes = (c.messages || []).map((m) => `
  • ${esc(m.message)}
  • `).join("") + (c.reason ? `
  • ${esc(c.reason)}
  • ` : ""); - return `${esc(c.id)}${esc(c.title)}${notes ? `` : ""} + const proposed = c.counted === false ? ` (${esc(c.status)}, not counted)` : ""; + return `${esc(c.id)}${esc(c.title)}${proposed}${notes ? `` : ""} ${esc(c.level)}${esc(c.result)}`; }).join(""); resultEl.innerHTML = `
    ${esc(d.summary.text)} (${d.summary.failed} failed, ${d.summary.warnings} with warnings, ${d.summary.skipped} not applicable or not checked) + ${d.summary.proposed_not_counted ? `
    Proposed criteria, not counted: ${esc(d.summary.proposed_not_counted.text)}
    ` : ""}
    Retrieved: ${esc(retrieval.url)} · HTTP ${esc(retrieval.status)} · ${esc(retrieval.contentType)}${retrieval.error ? " · " + esc(retrieval.error) : ""}
    ${esc(d.notice)}
    ${rows}
    CriterionRequirement and findingsLevelResult
    `; diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 8d13cce..78718e4 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -72,12 +72,22 @@ components: contentType: { type: string } summary: type: object + description: Only criteria with status active count. passed and warning count as passed, skipped is not counted. properties: text: { type: string, example: "7 of 7 automated checks passed" } passed: { type: integer } failed: { type: integer } warnings: { type: integer } skipped: { type: integer } + proposed_not_counted: + type: object + description: The same figures for criteria with status proposed, which are not counted. + properties: + text: { type: string } + passed: { type: integer } + failed: { type: integer } + warnings: { type: integer } + skipped: { type: integer } criteria: type: array items: @@ -87,6 +97,8 @@ components: title: { type: string } level: { type: string, enum: [MUST, SHOULD, MAY] } result: { type: string, enum: [passed, warning, failed, skipped] } + status: { type: string, enum: [proposed, active] } + counted: { type: boolean, description: true if the criterion counts in summary.text (status active) } reason: { type: string } messages: type: array diff --git a/test/services/passport_linter_test.rb b/test/services/passport_linter_test.rb index 7623fd1..299b2e7 100644 --- a/test/services/passport_linter_test.rb +++ b/test/services/passport_linter_test.rb @@ -27,7 +27,7 @@ def get(_url, accept: nil) "expect" => { "status" => [200], "content_type" => "application/json" } }] }.freeze - def criterion(check = CHECK) = { "id" => "DPP-DAT-016", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", "check" => check } + def criterion(check = CHECK) = { "id" => "DPP-DAT-016", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", "status" => "active", "check" => check } def lint(responses, check = CHECK) PassportLinter.new(catalogue: FakeCatalogue.new([criterion(check)]), resolver: FakeResolver.new(responses)) @@ -37,6 +37,18 @@ def lint(responses, check = CHECK) HTML = [200, "text/html", "", { "vary" => ["Accept"] }].freeze JSON_OK = [200, "application/json", '{"uniqueProductIdentifier":"x"}'].freeze + test "only active criteria count; proposed ones are summarised separately" do + proposed = criterion.merge("id" => "DPP-DAT-099", "status" => "proposed") + deprecated_like = criterion.merge("id" => "DPP-DAT-098", "status" => "deprecated") + report = PassportLinter.new(catalogue: FakeCatalogue.new([criterion, proposed, deprecated_like]), + resolver: FakeResolver.new("text/html" => HTML, "*/*" => JSON_OK)) + .run(passport: nil, product_id: UPI) + assert_equal "1 of 1 automated checks passed", report[:summary][:text] + assert_equal "1 of 1 automated checks passed", report[:summary][:proposed_not_counted][:text] + assert_equal [true, false, false], report[:criteria].map { |c| c[:counted] } + assert_equal %w[active proposed deprecated], report[:criteria].map { |c| c[:status] } + end + test "no failing check passes" do report = lint("text/html" => HTML, "*/*" => JSON_OK) assert_equal "passed", report[:criteria].first[:result] @@ -73,7 +85,7 @@ def lint(responses, check = CHECK) end test "applies_if with a matches pattern that is not valid ECMA-262 skips the criterion with a reason" do - criterion = { "id" => "DPP-ID-099", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", + criterion = { "id" => "DPP-ID-099", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", "status" => "active", "applies_if" => [{ "path" => "$.granularity", "matches" => "a**" }], "check" => { "type" => "did", "paths" => ["$.facilityId"] } } report = PassportLinter.new(catalogue: FakeCatalogue.new([criterion]), resolver: FakeResolver.new({})) .run(passport: { "granularity" => "item" }) @@ -83,7 +95,7 @@ def lint(responses, check = CHECK) end test "applies_if with ^ in search() skips the criterion with a reason instead of evaluating it" do - criterion = { "id" => "DPP-BAT-002", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", + criterion = { "id" => "DPP-BAT-002", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", "status" => "active", "applies_if" => [{ "path" => "$.contentSpecificationIds[?search(@, '^[Bb]atter')]", "exists" => false }], "check" => { "type" => "did", "paths" => ["$.facilityId"] } } report = PassportLinter.new(catalogue: FakeCatalogue.new([criterion]), resolver: FakeResolver.new({})) @@ -95,7 +107,7 @@ def lint(responses, check = CHECK) end test "applies_if with search() whose condition does not hold skips the criterion" do - criterion = { "id" => "DPP-BAT-002", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", + criterion = { "id" => "DPP-BAT-002", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", "status" => "active", "applies_if" => [{ "path" => "$.contentSpecificationIds[?search(@, '[Bb]atter')]", "exists" => true }], "check" => { "type" => "did", "paths" => ["$.facilityId"] } } report = PassportLinter.new(catalogue: FakeCatalogue.new([criterion]), resolver: FakeResolver.new({}))