From 2e2599d25770e69bc25c32536605724a57aba264 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 13 Oct 2026 09:47:29 +0000 Subject: [PATCH 01/14] feat: split module report pies into ASIL and QM The per-component status and test-coverage pie charts mixed QM and ASIL B Needs in a single figure, so neither safety class could be assessed on its own. Each component now gets two rows of charts: one restricted to ASIL B and one restricted to QM. QM Needs never reach ``valid_inspected``, so the QM status pie only knows ``valid`` and ``invalid``. There ``valid`` is the completed state and is therefore coloured green instead of the yellow "not inspected yet" that it denotes for ASIL B. The ASIL B colours stay as they were. The four near-identical needpie directives are replaced by a single ``safety_pie_row`` macro to keep the two safety classes in sync. The component overview, requirements traceability and architectural elements tables are now sorted by safety, so that all ASIL B entries precede the QM ones. --- .../module_verification_report.need | 95 ++++++++----------- 1 file changed, 42 insertions(+), 53 deletions(-) diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index cf95b48fa..2c53f504b 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -70,6 +70,39 @@ [{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] {%- endmacro %} +{#- One row of two pies (status and test coverage) restricted to a single + safety class, so that QM and ASIL figures are never mixed in one chart. + QM Needs never reach ``valid_inspected``, so there ``valid`` already is + the green "done" slice rather than the yellow "not inspected yet" one. -#} +{% macro safety_pie_row(title, subject, base, safety, safety_label) %} +{%- set scope = base ~ ' and safety == "' ~ safety ~ '"' %} +{%- set statuses = ["valid", "invalid"] if safety == "QM" else ["valid", "valid_inspected", "invalid"] %} +{%- set status_colors = "#37a12d, #ca2828" if safety == "QM" else "#f5c518, #37a12d, #ca2828" %} + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {{ title }} {{ subject }} Status ({{ safety_label }}) + :labels: {{ statuses|join(", ") }} + :colors: {{ status_colors }} + :legend: + +{% for state in statuses %} {{ scope }} and status == "{{ state }}" +{% endfor %} + .. grid-item:: + + .. needpie:: {{ title }} {{ subject }} Test Coverage ({{ safety_label }}) + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + {{ scope }} and fully_verifies_back + {{ scope }} and partially_verifies_back and not fully_verifies_back + {{ scope }} and not fully_verifies_back and not partially_verifies_back +{% endmacro %} + .. raw:: html {# ===================================================================== #} @@ -165,25 +210,35 @@ Component Overview {% set component_reqs_all = linked_needs(component_id, "satisfied_by_back") |selectattr("type", "eq", "comp_req") |list %} -{% set ns = namespace(req_ids=[]) %} +{% set ns = namespace(reqs=[]) %} {% for req in component_reqs_all %} {% if req_in_report_version(req, report_version) %} -{% set ns.req_ids = ns.req_ids + [req["id"]] %} +{% set ns.reqs = ns.reqs + [req] %} {% endif %} {% endfor %} -{% set component_req_filter = 'id in ' ~ id_filter_list(ns.req_ids) %} +{% set component_reqs = ns.reqs %} +{% set component_req_filter = 'id in ' ~ id_filter_list(component_reqs|map(attribute="id")|list) %} +{#- ``type`` has no "in" test in minijinja, so the two architecture types are + collected separately and concatenated. -#} +{% set component_arcs_all = linked_needs(component_id, "belongs_to_back") %} +{% set component_arcs = (component_arcs_all|selectattr("type", "eq", "comp_arc_sta")|list) + + (component_arcs_all|selectattr("type", "eq", "comp_arc_dyn")|list) %} Component Requirements Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ {% set req_base = 'type == "comp_req" and "' ~ component_id ~ '" in satisfied_by and ' ~ component_req_filter %} -{{ safety_pie_row(component_title, "Requirements", req_base, "ASIL_B", "ASIL B") }} -{{ safety_pie_row(component_title, "Requirements", req_base, "QM", "QM") }} +{{ safety_pie_row(component_title, "Requirements", req_base, "ASIL_B", "ASIL B", + component_reqs|selectattr("safety", "eq", "ASIL_B")|list|length) }} +{{ safety_pie_row(component_title, "Requirements", req_base, "QM", "QM", + component_reqs|selectattr("safety", "eq", "QM")|list|length) }} Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ {% set arc_base = 'type in ["comp_arc_sta", "comp_arc_dyn"] and "' ~ component_id ~ '" in belongs_to' %} -{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "ASIL_B", "ASIL B") }} -{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "QM", "QM") }} +{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "ASIL_B", "ASIL B", + component_arcs|selectattr("safety", "eq", "ASIL_B")|list|length) }} +{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "QM", "QM", + component_arcs|selectattr("safety", "eq", "QM")|list|length) }} Requirements Traceability ^^^^^^^^^^^^^^^^^^^^^^^^^ From 006a1f31fd8a49ccb6b8aa98e8a583d50e72ff4c Mon Sep 17 00:00:00 2001 From: antonkri Date: Tue, 13 Oct 2026 15:10:20 +0000 Subject: [PATCH 03/14] feat(report): split platform report pies into ASIL and QM Applies the module report's treatment to the platform report: the stakeholder, feature requirement and feature architecture rows are each rendered once per safety class, with a colour-coded rubric and placeholder tiles where a safety class has no Need. The reference-coverage pie is partitioned per safety class as well, so its slices match the row it sits in. --- .../platform_verification_report.need | 250 ++++++++++-------- 1 file changed, 144 insertions(+), 106 deletions(-) diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 9f6b30aad..463b069fd 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -61,16 +61,104 @@ {% macro id_filter_list(ids) -%} [{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] {%- endmacro %} + +{#- One row of pies restricted to a single safety class, so that QM and ASIL + figures are never mixed in one chart. QM Needs never reach + ``valid_inspected``, so there ``valid`` already is the green "done" slice + rather than the yellow "not inspected yet" one. + + ``with_reference`` adds the third, reference-coverage pie, which only the + requirement rows have; ``referenced``/``missing`` must already be + partitioned over this safety class alone. + + ``count`` decides between pies and placeholders only; it is deliberately + not shown, because it is resolved through the Need graph while the pies + count through Sphinx-Needs filters, and the two can disagree on links + carrying a ``[version==N]`` selector. -#} +{% macro safety_pie_row(title, subject, base, safety, safety_label, count, with_reference, referenced, missing) %} +{%- set scope = base ~ ' and safety == "' ~ safety ~ '"' %} +{%- set statuses = ["valid", "invalid"] if safety == "QM" else ["valid", "valid_inspected", "invalid"] %} +{%- set status_colors = "#37a12d, #ca2828" if safety == "QM" else "#f5c518, #37a12d, #ca2828" %} +{#- docutils normalises ``:class:`` values, so avoid underscores in them. -#} +{%- set safety_css = safety|lower|replace("_", "-") %} +{%- set chart_prefix = (title ~ " " ~ subject)|trim %} + +.. rubric:: {{ safety_label }} — {{ subject }} + :class: safety-class-heading safety-{{ safety_css }} + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + +{% if count == 0 %} + .. raw:: html + +
+ {{ subject }} Status ({{ safety_label }}) + no {{ safety_label }} {{ subject|lower }} +
+{% else %} + .. needpie:: {{ chart_prefix }} Status ({{ safety_label }}) + :labels: {{ statuses|join(", ") }} + :colors: {{ status_colors }} + :legend: + +{% for state in statuses %} {{ scope }} and status == "{{ state }}" +{% endfor %} +{% endif %} + .. grid-item:: + +{% if count == 0 %} + .. raw:: html + +
+ {{ subject }} Test Coverage ({{ safety_label }}) + no {{ safety_label }} {{ subject|lower }} +
+{% else %} + .. needpie:: {{ chart_prefix }} Test Coverage ({{ safety_label }}) + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + {{ scope }} and fully_verifies_back + {{ scope }} and partially_verifies_back and not fully_verifies_back + {{ scope }} and not fully_verifies_back and not partially_verifies_back +{% endif %} +{% if with_reference %} + .. grid-item:: + :class: score-centered-grid-item + +{% if count == 0 %} + .. raw:: html + +
+ {{ subject }} Reference Coverage ({{ safety_label }}) + no {{ safety_label }} {{ subject|lower }} +
+{% else %} + .. needpie:: {{ chart_prefix }} Reference Coverage ({{ safety_label }}) + :labels: referenced by lower, not referenced + :colors: #37a12d, #ca2828 + :legend: + + id in {{ id_filter_list(referenced) }} + id in {{ id_filter_list(missing) }} +{% endif %} +{% endif %} +{% endmacro %} {#- Stakeholder requirements are not scoped to a Feature, so they are collected once for the whole report rather than per-Feature like ``feat_req``. -#} {% set ns_stkh_reqs = namespace(list=[]) %} {% for req in needs_of_type("stkh_req")|unique(attribute="id")|list %} {% if req_in_report_version(req, report_version) %} -{% set ns_stkh_reqs.list = ns_stkh_reqs.list + [req["id"]] %} +{% set ns_stkh_reqs.list = ns_stkh_reqs.list + [req] %} {% endif %} {% endfor %} -{% set stkh_req_filter = 'id in ' ~ id_filter_list(ns_stkh_reqs.list) %} +{% set stkh_req_ids = ns_stkh_reqs.list|map(attribute="id")|list %} +{% set stkh_req_filter = 'id in ' ~ id_filter_list(stkh_req_ids) %} .. raw:: html {# ===================================================================== #} @@ -113,44 +218,17 @@ Stakeholder Requirements one in-scope feature requirement is ``derived_from`` it. The index is built and applied in Python, because every ``in`` test evaluated here would cross the Rust/Python boundary once per comparison. -#} -{% set ns_stkh_ref = split_by_reference( - ns_stkh_reqs.list, - referenced_ids("feat_req", "derived_from", report_version)) %} -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: Stakeholder Requirements Status - :labels: valid, valid_inspected, invalid - :colors: #f5c518, #37a12d, #ca2828 - :legend: - - type == "stkh_req" and status == "valid" and {{ stkh_req_filter }} - type == "stkh_req" and status == "valid_inspected" and {{ stkh_req_filter }} - type == "stkh_req" and status == "invalid" and {{ stkh_req_filter }} - - .. grid-item:: - - .. needpie:: Stakeholder Requirements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - type == "stkh_req" and fully_verifies_back and {{ stkh_req_filter }} - type == "stkh_req" and partially_verifies_back and not fully_verifies_back and {{ stkh_req_filter }} - type == "stkh_req" and not fully_verifies_back and not partially_verifies_back and {{ stkh_req_filter }} - - .. grid-item:: - :class: score-centered-grid-item - - .. needpie:: Stakeholder Requirements Reference Coverage - :labels: referenced by lower, not referenced - :colors: #37a12d, #ca2828 - :legend: - - id in {{ id_filter_list(ns_stkh_ref.referenced) }} - id in {{ id_filter_list(ns_stkh_ref.missing) }} +{% set stkh_referenced_ids = referenced_ids("feat_req", "derived_from", report_version) %} +{% set ns_stkh_ref = split_by_reference(stkh_req_ids, stkh_referenced_ids) %} +{% set stkh_base = 'type == "stkh_req" and ' ~ stkh_req_filter %} +{% set stkh_asil = ns_stkh_reqs.list|selectattr("safety", "eq", "ASIL_B")|list %} +{% set stkh_qm = ns_stkh_reqs.list|selectattr("safety", "eq", "QM")|list %} +{% set stkh_asil_ref = split_by_reference(stkh_asil|map(attribute="id")|list, stkh_referenced_ids) %} +{% set stkh_qm_ref = split_by_reference(stkh_qm|map(attribute="id")|list, stkh_referenced_ids) %} +{{ safety_pie_row("", "Stakeholder Requirements", stkh_base, "ASIL_B", "ASIL B", + stkh_asil|length, true, stkh_asil_ref.referenced, stkh_asil_ref.missing) }} +{{ safety_pie_row("", "Stakeholder Requirements", stkh_base, "QM", "QM", + stkh_qm|length, true, stkh_qm_ref.referenced, stkh_qm_ref.missing) }} .. dropdown:: Show requirements table :animate: fade-in @@ -162,7 +240,7 @@ Stakeholder Requirements :colwidths: 13,22,8,10,47 :sort: id {% if ns_stkh_ref.missing %} -{#- Detail view of the "not referenced" slice of the reference coverage pie +{#- Detail view of the "not referenced" slices of the reference coverage pies above; both are rendered from the same in-scope ID list. -#} .. dropdown:: Not referenced by feature requirements ({{ ns_stkh_ref.missing|length }}) @@ -217,54 +295,34 @@ Feature Overview {% set feature_reqs_all = linked_needs(feature_id, "satisfied_by_back") |selectattr("type", "eq", "feat_req") |list %} -{% set ns = namespace(req_ids=[]) %} +{% set ns = namespace(reqs=[]) %} {% for req in feature_reqs_all %} {% if req_in_report_version(req, report_version) %} -{% set ns.req_ids = ns.req_ids + [req["id"]] %} +{% set ns.reqs = ns.reqs + [req] %} {% endif %} {% endfor %} -{% set feature_req_filter = 'id in ' ~ id_filter_list(ns.req_ids) %} +{% set feature_reqs = ns.reqs %} +{% set feature_req_ids = feature_reqs|map(attribute="id")|list %} +{% set feature_req_filter = 'id in ' ~ id_filter_list(feature_req_ids) %} {#- Reference coverage: partition this Feature's in-scope requirements against the index built once above. -#} -{% set ns_feat_ref = split_by_reference(ns.req_ids, comp_referenced_ids) %} +{% set ns_feat_ref = split_by_reference(feature_req_ids, comp_referenced_ids) %} +{#- ``type`` has no "in" test in minijinja, so the two architecture types are + collected separately and concatenated. -#} +{% set feature_arcs_all = linked_needs(feature_id, "belongs_to_back") %} +{% set feature_arcs = (feature_arcs_all|selectattr("type", "eq", "feat_arc_sta")|list) + + (feature_arcs_all|selectattr("type", "eq", "feat_arc_dyn")|list) %} Requirements Statistics ^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {{ feature_title }} Requirements Status - :labels: valid, valid_inspected, invalid - :colors: #f5c518, #37a12d, #ca2828 - :legend: - - type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "valid" and {{ feature_req_filter }} - type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "valid_inspected" and {{ feature_req_filter }} - type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "invalid" and {{ feature_req_filter }} - - .. grid-item:: - - .. needpie:: {{ feature_title }} Requirements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - type == "feat_req" and "{{ feature_id }}" in satisfied_by and fully_verifies_back and {{ feature_req_filter }} - type == "feat_req" and "{{ feature_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back and {{ feature_req_filter }} - type == "feat_req" and "{{ feature_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back and {{ feature_req_filter }} - - .. grid-item:: - :class: score-centered-grid-item - - .. needpie:: {{ feature_title }} Requirements Reference Coverage - :labels: referenced by lower, not referenced - :colors: #37a12d, #ca2828 - :legend: - - id in {{ id_filter_list(ns_feat_ref.referenced) }} - id in {{ id_filter_list(ns_feat_ref.missing) }} +{% set req_base = 'type == "feat_req" and "' ~ feature_id ~ '" in satisfied_by and ' ~ feature_req_filter %} +{% set feat_asil = feature_reqs|selectattr("safety", "eq", "ASIL_B")|list %} +{% set feat_qm = feature_reqs|selectattr("safety", "eq", "QM")|list %} +{% set feat_asil_ref = split_by_reference(feat_asil|map(attribute="id")|list, comp_referenced_ids) %} +{% set feat_qm_ref = split_by_reference(feat_qm|map(attribute="id")|list, comp_referenced_ids) %} +{{ safety_pie_row(feature_title, "Requirements", req_base, "ASIL_B", "ASIL B", + feat_asil|length, true, feat_asil_ref.referenced, feat_asil_ref.missing) }} +{{ safety_pie_row(feature_title, "Requirements", req_base, "QM", "QM", + feat_qm|length, true, feat_qm_ref.referenced, feat_qm_ref.missing) }} .. dropdown:: Show requirements table :animate: fade-in @@ -291,31 +349,11 @@ Requirements Statistics Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {{ feature_title }} Architecture Elements Status - :labels: valid, valid_inspected, invalid - :colors: #f5c518, #37a12d, #ca2828 - :legend: - - type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and status == "valid" - type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and status == "valid_inspected" - type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and status == "invalid" - - .. grid-item:: - - .. needpie:: {{ feature_title }} Architecture Elements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and fully_verifies_back - type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and partially_verifies_back and not fully_verifies_back - type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and not fully_verifies_back and not partially_verifies_back +{% set arc_base = 'type in ["feat_arc_sta", "feat_arc_dyn"] and "' ~ feature_id ~ '" in belongs_to' %} +{{ safety_pie_row(feature_title, "Architecture Elements", arc_base, "ASIL_B", "ASIL B", + feature_arcs|selectattr("safety", "eq", "ASIL_B")|list|length, false, [], []) }} +{{ safety_pie_row(feature_title, "Architecture Elements", arc_base, "QM", "QM", + feature_arcs|selectattr("safety", "eq", "QM")|list|length, false, [], []) }} .. dropdown:: Show architectural elements table :animate: fade-in From 46dbf1c1cf5627ed8c34053923980fa1fd4d48ce Mon Sep 17 00:00:00 2001 From: antonkri Date: Wed, 14 Oct 2026 07:08:55 +0000 Subject: [PATCH 04/14] style(platform report): show all three pies in one row --- .../platform_verification_report.need | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 463b069fd..3c901a62a 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -82,13 +82,17 @@ {#- docutils normalises ``:class:`` values, so avoid underscores in them. -#} {%- set safety_css = safety|lower|replace("_", "-") %} {%- set chart_prefix = (title ~ " " ~ subject)|trim %} +{#- The three-pie rows are squeezed into a single row, which needs the charts + scaled down to the column width (see ``score-pie-row`` below). -#} +{%- set columns = "1 2 3 3" if with_reference else "1 2 2 2" %} .. rubric:: {{ safety_label }} — {{ subject }} :class: safety-class-heading safety-{{ safety_css }} -.. grid:: 1 2 2 2 +.. grid:: {{ columns }} :gutter: 3 - +{% if with_reference %} :class-row: score-pie-row +{% endif %} .. grid-item:: {% if count == 0 %} @@ -128,7 +132,6 @@ {% endif %} {% if with_reference %} .. grid-item:: - :class: score-centered-grid-item {% if count == 0 %} .. raw:: html @@ -183,10 +186,14 @@ .wp-doc-table td .dataTables_wrapper .dataTables_filter, .wp-doc-table td .dataTables_wrapper .dataTables_info, .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } - /* Centers a pie that wraps onto a row of its own in the two-column grids. */ - .score-centered-grid-item { - margin-left: auto; - margin-right: auto; + /* Shrinks the charts so that all three fit next to each other. */ + .score-pie-row img[id^="needpie-"] { + width: 100%; + height: auto; + } + .score-pie-row .empty-pie { + height: auto; + aspect-ratio: 16 / 9; } p.rubric.safety-class-heading { From bb255e59517b36132e5413031855d7e5d9b4027e Mon Sep 17 00:00:00 2001 From: antonkri Date: Wed, 14 Oct 2026 08:57:11 +0000 Subject: [PATCH 05/14] style(platform report): sort tables by safety class so ASIL B comes first --- .../platform_verification_report.need | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 3c901a62a..97a309e97 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -245,7 +245,7 @@ Stakeholder Requirements :style: table :columns: id;title;safety;status;testlink :colwidths: 13,22,8,10,47 - :sort: id + :sort: safety {% if ns_stkh_ref.missing %} {#- Detail view of the "not referenced" slices of the reference coverage pies above; both are rendered from the same in-scope ID list. -#} @@ -259,7 +259,7 @@ Stakeholder Requirements :style: table :columns: id;title;safety;status :colwidths: 15,45,15,25 - :sort: id + :sort: safety {% endif %} {# ===================================================================== #} @@ -276,7 +276,7 @@ Feature Overview :filter: id in [{% for feature in report_features %}"{{ feature["id"] }}"{% if not loop.last %}, {% endif %}{% endfor %}] :columns: id as "Feature";safety;security;status :style: table - :sort: id + :sort: safety {#- Reference coverage index, built once for all Features: a feature requirement is "referenced" when at least one in-scope component @@ -339,7 +339,7 @@ Requirements Statistics :style: table :columns: id;title;safety;status;testlink :colwidths: 13,22,8,10,47 - :sort: id + :sort: safety {% if ns_feat_ref.missing %} .. dropdown:: Not referenced by component requirements ({{ ns_feat_ref.missing|length }}) @@ -351,7 +351,7 @@ Requirements Statistics :style: table :columns: id;title;safety;status :colwidths: 15,45,15,25 - :sort: id + :sort: safety {% endif %} Architecture Statistics @@ -370,7 +370,7 @@ Architecture Statistics :style: table :columns: id;title;safety;status;tags :colwidths: 25,30,10,15,20 - :sort: id + :sort: safety Inspection Statistics ^^^^^^^^^^^^^^^^^^^^^ From a1b2251bb8316f5c6b6cf1726b2106bf555ab092 Mon Sep 17 00:00:00 2001 From: antonkri Date: Wed, 14 Oct 2026 11:11:30 +0000 Subject: [PATCH 06/14] feat(layout): put needpie legends below the pie Sphinx-Needs hardcodes figsize=(8, 4) and anchors the legend to the right of the pie, leaving the circle at ~40% of the image width. Three charts in one row shrank it past readability. Re-laying out the finished figure grows the circle to ~80% and makes the image nearly square, so rows of two and of three now match. --- src/extensions/score_layout/BUILD | 2 + src/extensions/score_layout/__init__.py | 2 + src/extensions/score_layout/needpie_layout.py | 66 +++++++++++++++ .../score_layout/tests/test_needpie_layout.py | 83 +++++++++++++++++++ .../module_verification_report.need | 12 ++- .../platform_verification_report.need | 19 ++--- 6 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 src/extensions/score_layout/needpie_layout.py create mode 100644 src/extensions/score_layout/tests/test_needpie_layout.py diff --git a/src/extensions/score_layout/BUILD b/src/extensions/score_layout/BUILD index c5240d6a3..583142f92 100644 --- a/src/extensions/score_layout/BUILD +++ b/src/extensions/score_layout/BUILD @@ -47,7 +47,9 @@ py_library( imports = ["."], visibility = ["//visibility:public"], deps = [ + requirement("matplotlib"), requirement("sphinx"), + requirement("sphinx-needs"), "//src/helper_lib", ], ) diff --git a/src/extensions/score_layout/__init__.py b/src/extensions/score_layout/__init__.py index 784865d0f..94844f83a 100644 --- a/src/extensions/score_layout/__init__.py +++ b/src/extensions/score_layout/__init__.py @@ -18,6 +18,7 @@ import html_options import sphinx_options +from needpie_layout import patch_needpie_layout from sphinx.application import Sphinx from src.helper_lib import Environment, config_setdefault @@ -54,6 +55,7 @@ def setup(app: Sphinx) -> dict[str, str | bool]: logger.debug("score_layout setup called") + patch_needpie_layout() app.connect("config-inited", update_config) # Run after the PyData theme creates its edit-URL callback so mounted pages # can replace the callback with a workspace-aware one. diff --git a/src/extensions/score_layout/needpie_layout.py b/src/extensions/score_layout/needpie_layout.py new file mode 100644 index 000000000..83cf6b54f --- /dev/null +++ b/src/extensions/score_layout/needpie_layout.py @@ -0,0 +1,66 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Re-layout Sphinx-Needs pie charts so they survive narrow columns. + +Sphinx-Needs hardcodes ``figsize=(8, 4)`` and anchors the legend to the right of +the pie, which leaves the circle filling only ~40% of the image width. Three +charts side by side shrink it past readability. Neither value is reachable from +the directive, so the finished figure is re-laid out just before it is written. +""" + +from typing import Any + +import sphinx_needs.directives.needpie +from docutils import nodes +from sphinx.application import Sphinx +from sphinx_needs.utils import save_matplotlib_figure + +# A square figure with the legend underneath grows the circle to ~80% of the +# image width, while the tight bounding box keeps the image barely wider than +# its title. +_FIGURE_SIZE_INCHES = (6, 6) +_LEGEND_FONT_SIZE = 9 + + +def _move_legend_below_pie(axes: Any) -> None: + legend = axes.get_legend() + if legend is None: + return + # ``Axes.legend`` replaces the current legend, so the old one needs no removal. + # The "legend" heading is dropped: it costs height and carries no information. + axes.legend( + list(legend.legend_handles), + [text.get_text() for text in legend.get_texts()], + loc="upper center", + bbox_to_anchor=(0.5, -0.02), + ncol=1, + frameon=False, + borderaxespad=0, + handlelength=1.2, + fontsize=_LEGEND_FONT_SIZE, + ) + + +def save_needpie_figure( + app: Sphinx, figure: Any, basename: str, fromdocname: str +) -> nodes.image: + """Drop-in for ``save_matplotlib_figure`` that restyles the figure first.""" + if figure.axes: + _move_legend_below_pie(figure.axes[0]) + figure.set_size_inches(*_FIGURE_SIZE_INCHES) + return save_matplotlib_figure(app, figure, basename, fromdocname) + + +def patch_needpie_layout() -> None: + """Patch only the ``needpie`` module, leaving ``needbar`` charts untouched.""" + sphinx_needs.directives.needpie.save_matplotlib_figure = save_needpie_figure diff --git a/src/extensions/score_layout/tests/test_needpie_layout.py b/src/extensions/score_layout/tests/test_needpie_layout.py new file mode 100644 index 000000000..7711e9cdf --- /dev/null +++ b/src/extensions/score_layout/tests/test_needpie_layout.py @@ -0,0 +1,83 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Tests for the pie chart re-layout applied to Sphinx-Needs figures.""" + +from typing import Any + +import matplotlib +import sphinx_needs.directives.needbar +import sphinx_needs.directives.needpie + +matplotlib.use("Agg") + +import matplotlib.pyplot # noqa: E402 + +from src.extensions.score_layout.needpie_layout import ( # noqa: E402 + _FIGURE_SIZE_INCHES, + _move_legend_below_pie, + patch_needpie_layout, + save_needpie_figure, +) + + +def _pie_beside_legend() -> tuple[Any, Any]: + """Build a figure shaped the way Sphinx-Needs leaves a needpie chart.""" + figure, axes = matplotlib.pyplot.subplots( + figsize=(8, 4), subplot_kw={"aspect": "equal"} + ) + wedges, _texts = axes.pie([3, 1]) + axes.legend( + wedges, + ["valid", "invalid"], + title="legend", + loc="center left", + bbox_to_anchor=(0.8, 0, 0.5, 1), + ) + return figure, axes + + +def test_patch_targets_needpie_only() -> None: + """``needbar`` charts have their own proportions and must stay untouched.""" + needbar_before = sphinx_needs.directives.needbar.save_matplotlib_figure + + patch_needpie_layout() + + assert sphinx_needs.directives.needpie.save_matplotlib_figure is save_needpie_figure + assert sphinx_needs.directives.needbar.save_matplotlib_figure is needbar_before + + +def test_legend_moves_below_the_pie() -> None: + """Freeing the space beside the pie is what lets the circle grow.""" + figure, axes = _pie_beside_legend() + figure.canvas.draw() + assert axes.get_legend().get_window_extent().y1 > axes.get_window_extent().y0 + + _move_legend_below_pie(axes) + figure.set_size_inches(*_FIGURE_SIZE_INCHES) + figure.canvas.draw() + + assert axes.get_legend().get_window_extent().y1 < axes.get_window_extent().y0 + matplotlib.pyplot.close(figure) + + +def test_chart_without_legend_is_only_resized() -> None: + """``:legend:`` is optional, so a missing legend must not raise.""" + figure, axes = matplotlib.pyplot.subplots( + figsize=(8, 4), subplot_kw={"aspect": "equal"} + ) + axes.pie([1, 1]) + + _move_legend_below_pie(axes) + + assert axes.get_legend() is None + matplotlib.pyplot.close(figure) diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index b9706566e..7a538dc47 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -90,7 +90,8 @@ :class: safety-class-heading safety-{{ safety_css }} .. grid:: 1 2 2 2 - :gutter: 3 + :gutter: 2 + :class-row: score-pie-row .. grid-item:: @@ -156,6 +157,12 @@ .wp-doc-table td .dataTables_wrapper .dataTables_info, .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } + /* Scales every chart to its column, so all rows match in size. */ + .score-pie-row img[id^="needpie-"] { + width: 100%; + height: auto; + } + p.rubric.safety-class-heading { display: inline-block; margin: 1.4em 0 0.5em 0; padding: 0.15em 0.9em; border-radius: 1em; @@ -164,10 +171,11 @@ p.rubric.safety-class-heading.safety-asil-b { background: #1f4e79; } p.rubric.safety-class-heading.safety-qm { background: #6c757d; } + /* Matches the aspect ratio of the rendered pie charts. */ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - height: 200px; padding: 0.5em; text-align: center; + aspect-ratio: 9 / 10; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 97a309e97..380cafbe7 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -82,17 +82,17 @@ {#- docutils normalises ``:class:`` values, so avoid underscores in them. -#} {%- set safety_css = safety|lower|replace("_", "-") %} {%- set chart_prefix = (title ~ " " ~ subject)|trim %} -{#- The three-pie rows are squeezed into a single row, which needs the charts - scaled down to the column width (see ``score-pie-row`` below). -#} +{#- The pies are scaled to the column width (see ``score-pie-row`` below), so + that a three-pie row stays as readable as a two-pie one. -#} {%- set columns = "1 2 3 3" if with_reference else "1 2 2 2" %} .. rubric:: {{ safety_label }} — {{ subject }} :class: safety-class-heading safety-{{ safety_css }} .. grid:: {{ columns }} - :gutter: 3 -{% if with_reference %} :class-row: score-pie-row -{% endif %} + :gutter: 2 + :class-row: score-pie-row + .. grid-item:: {% if count == 0 %} @@ -186,15 +186,11 @@ .wp-doc-table td .dataTables_wrapper .dataTables_filter, .wp-doc-table td .dataTables_wrapper .dataTables_info, .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } - /* Shrinks the charts so that all three fit next to each other. */ + /* Scales every chart to its column, so rows of two and of three match. */ .score-pie-row img[id^="needpie-"] { width: 100%; height: auto; } - .score-pie-row .empty-pie { - height: auto; - aspect-ratio: 16 / 9; - } p.rubric.safety-class-heading { display: inline-block; margin: 1.4em 0 0.5em 0; @@ -204,10 +200,11 @@ p.rubric.safety-class-heading.safety-asil-b { background: #1f4e79; } p.rubric.safety-class-heading.safety-qm { background: #6c757d; } + /* Matches the aspect ratio of the rendered pie charts. */ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - height: 200px; padding: 0.5em; text-align: center; + aspect-ratio: 9 / 10; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } From e2dda6a8109a9995d6b9949f3eec89588e96981f Mon Sep 17 00:00:00 2001 From: antonkri Date: Wed, 14 Oct 2026 11:18:15 +0000 Subject: [PATCH 07/14] revert(layout): drop the global needpie legend patch Moving the legend below the pie reshaped every needpie chart in S-CORE, not just the ones in the verification reports. Keep only the report-local grid changes: a smaller gutter and charts scaled to their column, so rows of two and of three come out the same size. --- src/extensions/score_layout/BUILD | 2 - src/extensions/score_layout/__init__.py | 2 - src/extensions/score_layout/needpie_layout.py | 66 --------------- .../score_layout/tests/test_needpie_layout.py | 83 ------------------- .../module_verification_report.need | 2 +- .../platform_verification_report.need | 2 +- 6 files changed, 2 insertions(+), 155 deletions(-) delete mode 100644 src/extensions/score_layout/needpie_layout.py delete mode 100644 src/extensions/score_layout/tests/test_needpie_layout.py diff --git a/src/extensions/score_layout/BUILD b/src/extensions/score_layout/BUILD index 583142f92..c5240d6a3 100644 --- a/src/extensions/score_layout/BUILD +++ b/src/extensions/score_layout/BUILD @@ -47,9 +47,7 @@ py_library( imports = ["."], visibility = ["//visibility:public"], deps = [ - requirement("matplotlib"), requirement("sphinx"), - requirement("sphinx-needs"), "//src/helper_lib", ], ) diff --git a/src/extensions/score_layout/__init__.py b/src/extensions/score_layout/__init__.py index 94844f83a..784865d0f 100644 --- a/src/extensions/score_layout/__init__.py +++ b/src/extensions/score_layout/__init__.py @@ -18,7 +18,6 @@ import html_options import sphinx_options -from needpie_layout import patch_needpie_layout from sphinx.application import Sphinx from src.helper_lib import Environment, config_setdefault @@ -55,7 +54,6 @@ def setup(app: Sphinx) -> dict[str, str | bool]: logger.debug("score_layout setup called") - patch_needpie_layout() app.connect("config-inited", update_config) # Run after the PyData theme creates its edit-URL callback so mounted pages # can replace the callback with a workspace-aware one. diff --git a/src/extensions/score_layout/needpie_layout.py b/src/extensions/score_layout/needpie_layout.py deleted file mode 100644 index 83cf6b54f..000000000 --- a/src/extensions/score_layout/needpie_layout.py +++ /dev/null @@ -1,66 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2025 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -"""Re-layout Sphinx-Needs pie charts so they survive narrow columns. - -Sphinx-Needs hardcodes ``figsize=(8, 4)`` and anchors the legend to the right of -the pie, which leaves the circle filling only ~40% of the image width. Three -charts side by side shrink it past readability. Neither value is reachable from -the directive, so the finished figure is re-laid out just before it is written. -""" - -from typing import Any - -import sphinx_needs.directives.needpie -from docutils import nodes -from sphinx.application import Sphinx -from sphinx_needs.utils import save_matplotlib_figure - -# A square figure with the legend underneath grows the circle to ~80% of the -# image width, while the tight bounding box keeps the image barely wider than -# its title. -_FIGURE_SIZE_INCHES = (6, 6) -_LEGEND_FONT_SIZE = 9 - - -def _move_legend_below_pie(axes: Any) -> None: - legend = axes.get_legend() - if legend is None: - return - # ``Axes.legend`` replaces the current legend, so the old one needs no removal. - # The "legend" heading is dropped: it costs height and carries no information. - axes.legend( - list(legend.legend_handles), - [text.get_text() for text in legend.get_texts()], - loc="upper center", - bbox_to_anchor=(0.5, -0.02), - ncol=1, - frameon=False, - borderaxespad=0, - handlelength=1.2, - fontsize=_LEGEND_FONT_SIZE, - ) - - -def save_needpie_figure( - app: Sphinx, figure: Any, basename: str, fromdocname: str -) -> nodes.image: - """Drop-in for ``save_matplotlib_figure`` that restyles the figure first.""" - if figure.axes: - _move_legend_below_pie(figure.axes[0]) - figure.set_size_inches(*_FIGURE_SIZE_INCHES) - return save_matplotlib_figure(app, figure, basename, fromdocname) - - -def patch_needpie_layout() -> None: - """Patch only the ``needpie`` module, leaving ``needbar`` charts untouched.""" - sphinx_needs.directives.needpie.save_matplotlib_figure = save_needpie_figure diff --git a/src/extensions/score_layout/tests/test_needpie_layout.py b/src/extensions/score_layout/tests/test_needpie_layout.py deleted file mode 100644 index 7711e9cdf..000000000 --- a/src/extensions/score_layout/tests/test_needpie_layout.py +++ /dev/null @@ -1,83 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License, Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -"""Tests for the pie chart re-layout applied to Sphinx-Needs figures.""" - -from typing import Any - -import matplotlib -import sphinx_needs.directives.needbar -import sphinx_needs.directives.needpie - -matplotlib.use("Agg") - -import matplotlib.pyplot # noqa: E402 - -from src.extensions.score_layout.needpie_layout import ( # noqa: E402 - _FIGURE_SIZE_INCHES, - _move_legend_below_pie, - patch_needpie_layout, - save_needpie_figure, -) - - -def _pie_beside_legend() -> tuple[Any, Any]: - """Build a figure shaped the way Sphinx-Needs leaves a needpie chart.""" - figure, axes = matplotlib.pyplot.subplots( - figsize=(8, 4), subplot_kw={"aspect": "equal"} - ) - wedges, _texts = axes.pie([3, 1]) - axes.legend( - wedges, - ["valid", "invalid"], - title="legend", - loc="center left", - bbox_to_anchor=(0.8, 0, 0.5, 1), - ) - return figure, axes - - -def test_patch_targets_needpie_only() -> None: - """``needbar`` charts have their own proportions and must stay untouched.""" - needbar_before = sphinx_needs.directives.needbar.save_matplotlib_figure - - patch_needpie_layout() - - assert sphinx_needs.directives.needpie.save_matplotlib_figure is save_needpie_figure - assert sphinx_needs.directives.needbar.save_matplotlib_figure is needbar_before - - -def test_legend_moves_below_the_pie() -> None: - """Freeing the space beside the pie is what lets the circle grow.""" - figure, axes = _pie_beside_legend() - figure.canvas.draw() - assert axes.get_legend().get_window_extent().y1 > axes.get_window_extent().y0 - - _move_legend_below_pie(axes) - figure.set_size_inches(*_FIGURE_SIZE_INCHES) - figure.canvas.draw() - - assert axes.get_legend().get_window_extent().y1 < axes.get_window_extent().y0 - matplotlib.pyplot.close(figure) - - -def test_chart_without_legend_is_only_resized() -> None: - """``:legend:`` is optional, so a missing legend must not raise.""" - figure, axes = matplotlib.pyplot.subplots( - figsize=(8, 4), subplot_kw={"aspect": "equal"} - ) - axes.pie([1, 1]) - - _move_legend_below_pie(axes) - - assert axes.get_legend() is None - matplotlib.pyplot.close(figure) diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 7a538dc47..b619c8111 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -175,7 +175,7 @@ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - aspect-ratio: 9 / 10; padding: 0.5em; text-align: center; + aspect-ratio: 16 / 9; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 380cafbe7..5e728c8ac 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -204,7 +204,7 @@ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - aspect-ratio: 9 / 10; padding: 0.5em; text-align: center; + aspect-ratio: 16 / 9; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } From 2380ca3ee7a039cb4048b2f0bbc33d90fe06e78a Mon Sep 17 00:00:00 2001 From: antonkri Date: Wed, 14 Oct 2026 12:26:23 +0000 Subject: [PATCH 08/14] feat: uniform pie size and a chart legend in both verification reports --- .../module_verification_report.need | 47 +++++++++++++++- .../platform_verification_report.need | 55 +++++++++++++++++-- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index b619c8111..1bce3af82 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -75,6 +75,10 @@ QM Needs never reach ``valid_inspected``, so there ``valid`` already is the green "done" slice rather than the yellow "not inspected yet" one. + The raster has three columns for two pies on purpose: the third column + stays empty so that these charts render at the same size as the three-pie + rows of the platform report. + ``count`` decides between pies and placeholders only; it is deliberately not shown, because it is resolved through the Need graph while the pies count through Sphinx-Needs filters, and the two can disagree on links @@ -89,7 +93,7 @@ .. rubric:: {{ safety_label }} — {{ subject }} :class: safety-class-heading safety-{{ safety_css }} -.. grid:: 1 2 2 2 +.. grid:: 1 2 3 3 :gutter: 2 :class-row: score-pie-row @@ -157,7 +161,8 @@ .wp-doc-table td .dataTables_wrapper .dataTables_info, .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } - /* Scales every chart to its column, so all rows match in size. */ + /* Scales every chart to its column; together with the fixed three-column + raster this gives every chart the same size as in the platform report. */ .score-pie-row img[id^="needpie-"] { width: 100%; height: auto; @@ -180,8 +185,46 @@ } .empty-pie-title { font-weight: 600; } .empty-pie-text { font-style: italic; } + + .chart-legend { display: flex; flex-wrap: wrap; gap: 1.2em 2.5em; } + .chart-legend-group { + display: flex; flex-direction: column; gap: 0.3em; + flex: 1 1 20em; font-size: 0.9em; + } + .chart-legend-head { font-weight: 600; } + .chart-legend-dot { + display: inline-block; width: 0.8em; height: 0.8em; + margin-right: 0.5em; border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.25); + } +.. dropdown:: Legend — status values and chart colours + :animate: fade-in + :open: + + .. raw:: html + +
+
+ Status — ASIL B + valid — agreed, formal inspection still outstanding + valid_inspected — agreed and formally inspected + invalid — not agreed; metamodel checks are skipped +
+
+ Status — QM + valid — agreed; QM needs are not formally inspected, so this is their final state + invalid — not agreed; metamodel checks are skipped +
+
+ Test coverage — both classes + fully covered — at least one test fully verifies the need + partially covered — only partially verifying tests + not covered — no verifying test at all +
+
+ {# ===================================================================== #} {# Components #} {# ===================================================================== #} diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 5e728c8ac..bcce51362 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -69,7 +69,9 @@ ``with_reference`` adds the third, reference-coverage pie, which only the requirement rows have; ``referenced``/``missing`` must already be - partitioned over this safety class alone. + partitioned over this safety class alone. The raster stays at three + columns either way, so that a two-pie row renders its charts at exactly + the same size as a three-pie one. ``count`` decides between pies and placeholders only; it is deliberately not shown, because it is resolved through the Need graph while the pies @@ -82,14 +84,11 @@ {#- docutils normalises ``:class:`` values, so avoid underscores in them. -#} {%- set safety_css = safety|lower|replace("_", "-") %} {%- set chart_prefix = (title ~ " " ~ subject)|trim %} -{#- The pies are scaled to the column width (see ``score-pie-row`` below), so - that a three-pie row stays as readable as a two-pie one. -#} -{%- set columns = "1 2 3 3" if with_reference else "1 2 2 2" %} .. rubric:: {{ safety_label }} — {{ subject }} :class: safety-class-heading safety-{{ safety_css }} -.. grid:: {{ columns }} +.. grid:: 1 2 3 3 :gutter: 2 :class-row: score-pie-row @@ -186,7 +185,8 @@ .wp-doc-table td .dataTables_wrapper .dataTables_filter, .wp-doc-table td .dataTables_wrapper .dataTables_info, .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } - /* Scales every chart to its column, so rows of two and of three match. */ + /* Scales every chart to its column; together with the fixed three-column + raster this gives every chart in the report the same size. */ .score-pie-row img[id^="needpie-"] { width: 100%; height: auto; @@ -209,8 +209,51 @@ } .empty-pie-title { font-weight: 600; } .empty-pie-text { font-style: italic; } + + .chart-legend { display: flex; flex-wrap: wrap; gap: 1.2em 2.5em; } + .chart-legend-group { + display: flex; flex-direction: column; gap: 0.3em; + flex: 1 1 20em; font-size: 0.9em; + } + .chart-legend-head { font-weight: 600; } + .chart-legend-dot { + display: inline-block; width: 0.8em; height: 0.8em; + margin-right: 0.5em; border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.25); + } +.. dropdown:: Legend — status values and chart colours + :animate: fade-in + :open: + + .. raw:: html + +
+
+ Status — ASIL B + valid — agreed, formal inspection still outstanding + valid_inspected — agreed and formally inspected + invalid — not agreed; metamodel checks are skipped +
+
+ Status — QM + valid — agreed; QM needs are not formally inspected, so this is their final state + invalid — not agreed; metamodel checks are skipped +
+
+ Test coverage — both classes + fully covered — at least one test fully verifies the need + partially covered — only partially verifying tests + not covered — no verifying test at all +
+
+ Reference coverage — both classes + referenced by lower — a requirement one level down derives from it + not referenced — nothing on the level below derives from it +
+
+ {# ===================================================================== #} {# Stakeholder Requirements #} {# ===================================================================== #} From c3a72ccd9b5691ae7ffb31483da71dff908716dc Mon Sep 17 00:00:00 2001 From: antonkri Date: Wed, 14 Oct 2026 13:07:52 +0000 Subject: [PATCH 09/14] fix: centre the two-pie rows in the three-column raster --- src/needs_templates/module_verification_report.need | 3 +++ src/needs_templates/platform_verification_report.need | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 1bce3af82..ce700c51e 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -167,6 +167,9 @@ width: 100%; height: auto; } + /* ``.sd-row`` is a flex container without a justification of its own, so + this centres the two pies in the three-column raster. */ + .score-pie-row { justify-content: center; } p.rubric.safety-class-heading { display: inline-block; margin: 1.4em 0 0.5em 0; diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index bcce51362..f1dd65f84 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -191,6 +191,9 @@ width: 100%; height: auto; } + /* ``.sd-row`` is a flex container without a justification of its own, so + this centres the rows that leave the third column empty. */ + .score-pie-row { justify-content: center; } p.rubric.safety-class-heading { display: inline-block; margin: 1.4em 0 0.5em 0; From d480bc14f68ca68e756fc40e040a8b29e38b2bf2 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Thu, 15 Oct 2026 08:16:59 +0000 Subject: [PATCH 10/14] refactor: share safety_pie_row between the verification reports MiniJinja has no template loader, so the macro could not be imported and was duplicated in both templates. Implement it once as a render helper. --- .../score_sphinx_needs_templates/__init__.py | 123 ++++++++++++++++++ .../module_verification_report.need | 68 +--------- .../platform_verification_report.need | 91 +------------ 3 files changed, 129 insertions(+), 153 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 806adb609..5aa05bc4b 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -290,6 +290,126 @@ def __call__(self, ids: list[str], referenced: list[str]) -> dict[str, list[str] _split_by_reference_callable = _SplitByReference() +def _id_filter_list(ids: list[str]) -> str: + """Render IDs as a quoted Sphinx-Needs ``id in [...]`` filter literal.""" + return "[" + ", ".join(f'"{need_id}"' for need_id in ids) + "]" + + +class _SafetyPieRow: + """Render one row of pies restricted to a single safety class. + + Shared by the module and platform verification reports, so that QM and + ASIL figures are never mixed in one chart. QM Needs never reach + ``valid_inspected``, so there ``valid`` already is the green "done" slice + rather than the yellow "not inspected yet" one. + + ``with_reference`` adds a third, reference-coverage pie; ``referenced`` and + ``missing`` must already be partitioned over this safety class alone. The + raster stays at three columns either way, so that a two-pie row renders + its charts at exactly the same size as a three-pie one. + + ``count`` decides between pies and placeholders only; it is deliberately + not shown, because it is resolved through the Need graph while the pies + count through Sphinx-Needs filters, and the two can disagree on links + carrying a ``[version==N]`` selector. + + This is a render helper instead of a Jinja macro because the MiniJinja + environment has no template loader, so macros cannot be shared between + templates. + """ + + def __call__( + self, + title: str, + subject: str, + base: str, + safety: str, + safety_label: str, + count: int, + with_reference: bool = False, + referenced: list[str] | None = None, + missing: list[str] | None = None, + ) -> str: + scope = f'{base} and safety == "{safety}"' + statuses = ( + ["valid", "invalid"] + if safety == "QM" + else ["valid", "valid_inspected", "invalid"] + ) + status_colors = ( + "#37a12d, #ca2828" if safety == "QM" else "#f5c518, #37a12d, #ca2828" + ) + # docutils normalises ``:class:`` values, so avoid underscores in them. + safety_css = safety.lower().replace("_", "-") + chart_prefix = f"{title} {subject}".strip() + + lines = [ + "", + f".. rubric:: {safety_label} — {subject}", + f" :class: safety-class-heading safety-{safety_css}", + "", + ".. grid:: 1 2 3 3", + " :gutter: 2", + " :class-row: score-pie-row", + ] + + def grid_item(name: str, pie: list[str]) -> None: + lines.extend(["", " .. grid-item::", ""]) + if count == 0: + lines.extend( + [ + " .. raw:: html", + "", + '
', + f' {subject} {name} ({safety_label})', + f' no {safety_label} {subject.lower()}', + "
", + ] + ) + else: + lines.append(f" .. needpie:: {chart_prefix} {name} ({safety_label})") + lines.extend(f" {line}".rstrip() for line in pie) + + grid_item( + "Status", + [ + f":labels: {', '.join(statuses)}", + f":colors: {status_colors}", + ":legend:", + "", + *(f'{scope} and status == "{state}"' for state in statuses), + ], + ) + grid_item( + "Test Coverage", + [ + ":labels: fully covered, partially covered, not covered", + ":colors: #37a12d, #f0a500, #ca2828", + ":legend:", + "", + f"{scope} and fully_verifies_back", + f"{scope} and partially_verifies_back and not fully_verifies_back", + f"{scope} and not fully_verifies_back and not partially_verifies_back", + ], + ) + if with_reference: + grid_item( + "Reference Coverage", + [ + ":labels: referenced by lower, not referenced", + ":colors: #37a12d, #ca2828", + ":legend:", + "", + f"id in {_id_filter_list(referenced or [])}", + f"id in {_id_filter_list(missing or [])}", + ], + ) + return "\n".join(lines) + "\n" + + +_safety_pie_row_callable = _SafetyPieRow() + + def _post_templates_requiring_reread(app: Sphinx) -> set[str]: """Return post-template names opting into the post-merge rendering pass.""" template_folder = _needs_template_folder() @@ -386,6 +506,9 @@ def setup(app: Sphinx) -> dict[str, object]: app.config.needs_render_context.setdefault( "split_by_reference", _split_by_reference_callable ) + app.config.needs_render_context.setdefault( + "safety_pie_row", _safety_pie_row_callable + ) app.connect("builder-inited", _capture_build_environment) # Run after the source-code linker has injected generated testcase Needs and # their verification backlinks (priority 525), so report templates can diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 4b5ee13ef..67df62599 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -78,71 +78,9 @@ [{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] {%- endmacro %} -{#- One row of two pies (status and test coverage) restricted to a single - safety class, so that QM and ASIL figures are never mixed in one chart. - QM Needs never reach ``valid_inspected``, so there ``valid`` already is - the green "done" slice rather than the yellow "not inspected yet" one. - - The raster has three columns for two pies on purpose: the third column - stays empty so that these charts render at the same size as the three-pie - rows of the platform report. - - ``count`` decides between pies and placeholders only; it is deliberately - not shown, because it is resolved through the Need graph while the pies - count through Sphinx-Needs filters, and the two can disagree on links - carrying a ``[version==N]`` selector. -#} -{% macro safety_pie_row(title, subject, base, safety, safety_label, count) %} -{%- set scope = base ~ ' and safety == "' ~ safety ~ '"' %} -{%- set statuses = ["valid", "invalid"] if safety == "QM" else ["valid", "valid_inspected", "invalid"] %} -{%- set status_colors = "#37a12d, #ca2828" if safety == "QM" else "#f5c518, #37a12d, #ca2828" %} -{#- docutils normalises ``:class:`` values, so avoid underscores in them. -#} -{%- set safety_css = safety|lower|replace("_", "-") %} - -.. rubric:: {{ safety_label }} — {{ subject }} - :class: safety-class-heading safety-{{ safety_css }} - -.. grid:: 1 2 3 3 - :gutter: 2 - :class-row: score-pie-row - - .. grid-item:: - -{% if count == 0 %} - .. raw:: html - -
- {{ subject }} Status ({{ safety_label }}) - no {{ safety_label }} {{ subject|lower }} -
-{% else %} - .. needpie:: {{ title }} {{ subject }} Status ({{ safety_label }}) - :labels: {{ statuses|join(", ") }} - :colors: {{ status_colors }} - :legend: - -{% for state in statuses %} {{ scope }} and status == "{{ state }}" -{% endfor %} -{% endif %} - .. grid-item:: - -{% if count == 0 %} - .. raw:: html - -
- {{ subject }} Test Coverage ({{ safety_label }}) - no {{ safety_label }} {{ subject|lower }} -
-{% else %} - .. needpie:: {{ title }} {{ subject }} Test Coverage ({{ safety_label }}) - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - {{ scope }} and fully_verifies_back - {{ scope }} and partially_verifies_back and not fully_verifies_back - {{ scope }} and not fully_verifies_back and not partially_verifies_back -{% endif %} -{% endmacro %} +{#- ``safety_pie_row`` is a render helper shared with the platform report. + The module report passes no ``with_reference``, so each row is two pies + in the same three-column raster as the platform report's three-pie rows. -#} .. raw:: html diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index f1dd65f84..18fbe1c6e 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -62,94 +62,9 @@ [{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] {%- endmacro %} -{#- One row of pies restricted to a single safety class, so that QM and ASIL - figures are never mixed in one chart. QM Needs never reach - ``valid_inspected``, so there ``valid`` already is the green "done" slice - rather than the yellow "not inspected yet" one. - - ``with_reference`` adds the third, reference-coverage pie, which only the - requirement rows have; ``referenced``/``missing`` must already be - partitioned over this safety class alone. The raster stays at three - columns either way, so that a two-pie row renders its charts at exactly - the same size as a three-pie one. - - ``count`` decides between pies and placeholders only; it is deliberately - not shown, because it is resolved through the Need graph while the pies - count through Sphinx-Needs filters, and the two can disagree on links - carrying a ``[version==N]`` selector. -#} -{% macro safety_pie_row(title, subject, base, safety, safety_label, count, with_reference, referenced, missing) %} -{%- set scope = base ~ ' and safety == "' ~ safety ~ '"' %} -{%- set statuses = ["valid", "invalid"] if safety == "QM" else ["valid", "valid_inspected", "invalid"] %} -{%- set status_colors = "#37a12d, #ca2828" if safety == "QM" else "#f5c518, #37a12d, #ca2828" %} -{#- docutils normalises ``:class:`` values, so avoid underscores in them. -#} -{%- set safety_css = safety|lower|replace("_", "-") %} -{%- set chart_prefix = (title ~ " " ~ subject)|trim %} - -.. rubric:: {{ safety_label }} — {{ subject }} - :class: safety-class-heading safety-{{ safety_css }} - -.. grid:: 1 2 3 3 - :gutter: 2 - :class-row: score-pie-row - - .. grid-item:: - -{% if count == 0 %} - .. raw:: html - -
- {{ subject }} Status ({{ safety_label }}) - no {{ safety_label }} {{ subject|lower }} -
-{% else %} - .. needpie:: {{ chart_prefix }} Status ({{ safety_label }}) - :labels: {{ statuses|join(", ") }} - :colors: {{ status_colors }} - :legend: - -{% for state in statuses %} {{ scope }} and status == "{{ state }}" -{% endfor %} -{% endif %} - .. grid-item:: - -{% if count == 0 %} - .. raw:: html - -
- {{ subject }} Test Coverage ({{ safety_label }}) - no {{ safety_label }} {{ subject|lower }} -
-{% else %} - .. needpie:: {{ chart_prefix }} Test Coverage ({{ safety_label }}) - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - {{ scope }} and fully_verifies_back - {{ scope }} and partially_verifies_back and not fully_verifies_back - {{ scope }} and not fully_verifies_back and not partially_verifies_back -{% endif %} -{% if with_reference %} - .. grid-item:: - -{% if count == 0 %} - .. raw:: html - -
- {{ subject }} Reference Coverage ({{ safety_label }}) - no {{ safety_label }} {{ subject|lower }} -
-{% else %} - .. needpie:: {{ chart_prefix }} Reference Coverage ({{ safety_label }}) - :labels: referenced by lower, not referenced - :colors: #37a12d, #ca2828 - :legend: - - id in {{ id_filter_list(referenced) }} - id in {{ id_filter_list(missing) }} -{% endif %} -{% endif %} -{% endmacro %} +{#- ``safety_pie_row`` is a render helper shared with the module report. Its + ``with_reference`` flag adds the third, reference-coverage pie, which only + the requirement rows have. -#} {#- Stakeholder requirements are not scoped to a Feature, so they are collected once for the whole report rather than per-Feature like ``feat_req``. -#} From 2828fcc9248846f4acca2e977ba18bf2f841c82f Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Thu, 15 Oct 2026 08:19:40 +0000 Subject: [PATCH 11/14] test: cover the shared safety_pie_row helper Also apply ruff format to the helper. --- .../score_sphinx_needs_templates/__init__.py | 4 +- .../tests/test_needs_templates.py | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 5aa05bc4b..3f1f9dc50 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -367,7 +367,9 @@ def grid_item(name: str, pie: list[str]) -> None: ] ) else: - lines.append(f" .. needpie:: {chart_prefix} {name} ({safety_label})") + lines.append( + f" .. needpie:: {chart_prefix} {name} ({safety_label})" + ) lines.extend(f" {line}".rstrip() for line in pie) grid_item( diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index bddb56735..3ea652694 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py @@ -173,6 +173,46 @@ def test_any_req_in_report_version_false_if_no_requirement_matches() -> None: assert any_req_in_report_version([a, b], "v1.0") is False +def test_safety_pie_row_qm_has_no_inspected_slice() -> None: + """QM Needs are never formally inspected, so ``valid`` is their final slice.""" + safety_pie_row = vars(templates)["_SafetyPieRow"]() + + qm = safety_pie_row("Comp", "Requirements", "BASE", "QM", "QM", 1) + asil = safety_pie_row("Comp", "Requirements", "BASE", "ASIL_B", "ASIL B", 1) + + assert "valid_inspected" not in qm + assert ":labels: valid, valid_inspected, invalid" in asil + assert 'BASE and safety == "ASIL_B" and status == "valid_inspected"' in asil + assert ":class: safety-class-heading safety-asil-b" in asil + + +def test_safety_pie_row_empty_class_renders_placeholders_only() -> None: + safety_pie_row = vars(templates)["_SafetyPieRow"]() + + row = safety_pie_row("Comp", "Requirements", "BASE", "QM", "QM", 0, True) + + assert "needpie" not in row + assert row.count('
') == 3 + assert "no QM requirements" in row + + +def test_safety_pie_row_reference_pie_only_on_request() -> None: + safety_pie_row = vars(templates)["_SafetyPieRow"]() + + without = safety_pie_row("", "Stakeholder Requirements", "B", "QM", "QM", 2) + with_ref = safety_pie_row( + "", "Stakeholder Requirements", "B", "QM", "QM", 2, True, ["a", "b"], ["c"] + ) + + assert "Reference Coverage" not in without + assert without.count(".. needpie::") == 2 + assert with_ref.count(".. needpie::") == 3 + assert 'id in ["a", "b"]' in with_ref + assert 'id in ["c"]' in with_ref + # An empty title must not leave a leading blank in the chart title. + assert ".. needpie:: Stakeholder Requirements Status (QM)" in with_ref + + def test_tool_qualification_matrix_groups_shared_requirements( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 4443cb9ed65eb1b3d5dff2a8c19a3f7580b765f5 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Thu, 15 Oct 2026 08:23:06 +0000 Subject: [PATCH 12/14] refactor: share id_filter_list and workproduct_rows as render helpers The macros were duplicated in the module, platform and tool qualification templates. The helpers render byte-identical output. --- .../score_sphinx_needs_templates/README.md | 2 + .../score_sphinx_needs_templates/__init__.py | 58 +++++++++++++++++-- .../tests/test_needs_templates.py | 18 ++++++ .../module_verification_report.need | 30 +--------- .../platform_verification_report.need | 30 +--------- .../tool_qualification_report.need | 4 -- 6 files changed, 77 insertions(+), 65 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md index fa76b5a7a..31d0e8111 100644 --- a/src/extensions/score_sphinx_needs_templates/README.md +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -25,6 +25,8 @@ The extension provides: directory; * the `linked_needs(need_id, link_name)` helper for traversing Need links; * the `needs_of_type(need_type)` helper for selecting Needs by type; +* the `id_filter_list`, `workproduct_rows` and `safety_pie_row` helpers, which + render RST fragments shared by several report templates; * support for graph-driven `post_template`s that are rendered after parallel Need collection has been merged; * ordinary Sphinx page navigation for sections generated by those diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 3f1f9dc50..752367b61 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -290,9 +290,51 @@ def __call__(self, ids: list[str], referenced: list[str]) -> dict[str, list[str] _split_by_reference_callable = _SplitByReference() -def _id_filter_list(ids: list[str]) -> str: - """Render IDs as a quoted Sphinx-Needs ``id in [...]`` filter literal.""" - return "[" + ", ".join(f'"{need_id}"' for need_id in ids) + "]" +class _IdFilterList: + """Render Need IDs as the quoted ``[...]`` literal of an ``id in`` filter.""" + + def __call__(self, ids: list[str]) -> str: + return "[" + ", ".join(f'"{need_id}"' for need_id in ids) + "]" + + +_id_filter_list_callable = _IdFilterList() + + +class _WorkproductRows: + """Render work-product rows for a six-space indented ``list-table``. + + Each row links the work product, its kind, the realising document and its + status. Both document cells are needtables over the same filter, differing + only in ``:columns:``, so a missing document renders as an empty cell. + """ + + def __call__(self, slug_norm: str, workproducts: list[list[str]]) -> str: + rows: list[str] = [] + for wp_id, kind in workproducts: + doc_filter = ( + f'type == "document" and "{slug_norm}" in id.replace("_", "").lower()' + f' and "{wp_id}" in realizes' + ) + rows.append( + "\n".join( + [ + f" * - :need:`{wp_id}`", + f" - {kind}", + " - .. needtable::", + f" :filter: {doc_filter}", + " :columns: id", + " :style: table", + " - .. needtable::", + f" :filter: {doc_filter}", + " :columns: status", + " :style: table", + ] + ) + ) + return "".join(f"\n{row}" for row in rows) + "\n" + + +_workproduct_rows_callable = _WorkproductRows() class _SafetyPieRow: @@ -402,8 +444,8 @@ def grid_item(name: str, pie: list[str]) -> None: ":colors: #37a12d, #ca2828", ":legend:", "", - f"id in {_id_filter_list(referenced or [])}", - f"id in {_id_filter_list(missing or [])}", + f"id in {_id_filter_list_callable(referenced or [])}", + f"id in {_id_filter_list_callable(missing or [])}", ], ) return "\n".join(lines) + "\n" @@ -508,6 +550,12 @@ def setup(app: Sphinx) -> dict[str, object]: app.config.needs_render_context.setdefault( "split_by_reference", _split_by_reference_callable ) + app.config.needs_render_context.setdefault( + "id_filter_list", _id_filter_list_callable + ) + app.config.needs_render_context.setdefault( + "workproduct_rows", _workproduct_rows_callable + ) app.config.needs_render_context.setdefault( "safety_pie_row", _safety_pie_row_callable ) diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index 3ea652694..de807e647 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py @@ -213,6 +213,24 @@ def test_safety_pie_row_reference_pie_only_on_request() -> None: assert ".. needpie:: Stakeholder Requirements Status (QM)" in with_ref +def test_id_filter_list_renders_quoted_literal() -> None: + id_filter_list = vars(templates)["_IdFilterList"]() + + assert id_filter_list(["a", "b"]) == '["a", "b"]' + assert id_filter_list([]) == "[]" + + +def test_workproduct_rows_filter_by_slug_and_work_product() -> None: + workproduct_rows = vars(templates)["_WorkproductRows"]() + + rows = workproduct_rows("mycomp", [["wp__dfa", "DFA"], ["wp__fmea", "FMEA"]]) + + assert rows.count(" * - :need:`") == 2 + assert " - DFA\n" in rows + assert rows.count('"mycomp" in id.replace("_", "").lower()') == 4 + assert rows.count('and "wp__fmea" in realizes') == 2 + + def test_tool_qualification_matrix_groups_shared_requirements( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 67df62599..d3494fab7 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -53,34 +53,8 @@ ["wp__sw_component_fmea", "FMEA"], ] %} -{#- One work-product row: the need link, its kind, the realising document and - its status. Both cells are needtables over the same filter, differing only - in :columns:, so an empty match renders as an empty cell. -#} -{% macro workproduct_rows(slug_norm, workproducts) %} -{%- for wp in workproducts %} - * - :need:`{{ wp[0] }}` - - {{ wp[1] }} - - .. needtable:: - :filter: type == "document" and "{{ slug_norm }}" in id.replace("_", "").lower() and "{{ wp[0] }}" in realizes - :columns: id - :style: table - - .. needtable:: - :filter: type == "document" and "{{ slug_norm }}" in id.replace("_", "").lower() and "{{ wp[0] }}" in realizes - :columns: status - :style: table -{%- endfor %} -{% endmacro %} - -{#- Bracketed, quoted Sphinx-Needs ``id in [...]`` filter literal for a list - of Need IDs, used to scope needpie/needtable filters below to the - requirements that are in scope for ``applies_to_module_version``. -#} -{% macro id_filter_list(ids) -%} -[{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] -{%- endmacro %} - -{#- ``safety_pie_row`` is a render helper shared with the platform report. - The module report passes no ``with_reference``, so each row is two pies - in the same three-column raster as the platform report's three-pie rows. -#} +{#- ``workproduct_rows``, ``id_filter_list`` and ``safety_pie_row`` are render + helpers shared with the platform report. -#} .. raw:: html diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 18fbe1c6e..ac0e222f4 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -37,34 +37,8 @@ ["wp__feature_fmea", "FMEA"], ] %} -{#- One work-product row: the need link, its kind, the realising document and - its status. Both cells are needtables over the same filter, differing only - in :columns:, so an empty match renders as an empty cell. -#} -{% macro workproduct_rows(slug_norm, workproducts) %} -{%- for wp in workproducts %} - * - :need:`{{ wp[0] }}` - - {{ wp[1] }} - - .. needtable:: - :filter: type == "document" and "{{ slug_norm }}" in id.replace("_", "").lower() and "{{ wp[0] }}" in realizes - :columns: id - :style: table - - .. needtable:: - :filter: type == "document" and "{{ slug_norm }}" in id.replace("_", "").lower() and "{{ wp[0] }}" in realizes - :columns: status - :style: table -{%- endfor %} -{% endmacro %} - -{#- Bracketed, quoted Sphinx-Needs ``id in [...]`` filter literal for a list - of Need IDs, used to scope needpie/needtable filters below to the - requirements that are in scope for ``report_version``. -#} -{% macro id_filter_list(ids) -%} -[{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] -{%- endmacro %} - -{#- ``safety_pie_row`` is a render helper shared with the module report. Its - ``with_reference`` flag adds the third, reference-coverage pie, which only - the requirement rows have. -#} +{#- ``workproduct_rows``, ``id_filter_list`` and ``safety_pie_row`` are render + helpers shared with the module report. -#} {#- Stakeholder requirements are not scoped to a Feature, so they are collected once for the whole report rather than per-Feature like ``feat_req``. -#} diff --git a/src/needs_templates/tool_qualification_report.need b/src/needs_templates/tool_qualification_report.need index d27cbf9ff..ff2c2bbe5 100644 --- a/src/needs_templates/tool_qualification_report.need +++ b/src/needs_templates/tool_qualification_report.need @@ -29,10 +29,6 @@ {{ ((value|default("", true)|replace("\n", " ")|trim) or fallback)|replace("\\", "\\\\")|replace('"', '\\"') }} {%- endmacro %} -{% macro id_filter_list(ids) -%} -[{% for need_id in ids %}"{{ need_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] -{%- endmacro %} - {% set usecase_malfunction_scope = namespace(with_malfunctions=[], without_malfunctions=[]) %} {% for usecase in usecases %} {% if linked_needs(usecase["id"], "parent_needs_back")|list %} From fdd8096429972940960265a9f0f0297e5fc659bd Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Thu, 15 Oct 2026 11:46:52 +0000 Subject: [PATCH 13/14] fix: count valid_inspected QM Needs as valid in the status pie The QM status pie only had valid and invalid slices, so a QM Need marked valid_inspected was silently dropped from it while the coverage pie still counted it. Treat it as valid, like the metamodel graph checks do. --- .../score_sphinx_needs_templates/__init__.py | 22 +++++++++++++------ .../tests/test_needs_templates.py | 10 ++++++--- .../module_verification_report.need | 2 +- .../platform_verification_report.need | 2 +- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 752367b61..fc72014ca 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -341,9 +341,10 @@ class _SafetyPieRow: """Render one row of pies restricted to a single safety class. Shared by the module and platform verification reports, so that QM and - ASIL figures are never mixed in one chart. QM Needs never reach - ``valid_inspected``, so there ``valid`` already is the green "done" slice - rather than the yellow "not inspected yet" one. + ASIL figures are never mixed in one chart. QM Needs are not formally + inspected, so there ``valid`` already is the green "done" slice rather + than the yellow "not inspected yet" one; a QM Need that is nevertheless + ``valid_inspected`` counts as ``valid``, as in the metamodel graph checks. ``with_reference`` adds a third, reference-coverage pie; ``referenced`` and ``missing`` must already be partitioned over this safety class alone. The @@ -373,10 +374,17 @@ def __call__( missing: list[str] | None = None, ) -> str: scope = f'{base} and safety == "{safety}"' + # (slice label, status condition) per pie slice. statuses = ( - ["valid", "invalid"] + [ + ("valid", '(status == "valid" or status == "valid_inspected")'), + ("invalid", 'status == "invalid"'), + ] if safety == "QM" - else ["valid", "valid_inspected", "invalid"] + else [ + (state, f'status == "{state}"') + for state in ("valid", "valid_inspected", "invalid") + ] ) status_colors = ( "#37a12d, #ca2828" if safety == "QM" else "#f5c518, #37a12d, #ca2828" @@ -417,11 +425,11 @@ def grid_item(name: str, pie: list[str]) -> None: grid_item( "Status", [ - f":labels: {', '.join(statuses)}", + f":labels: {', '.join(label for label, _ in statuses)}", f":colors: {status_colors}", ":legend:", "", - *(f'{scope} and status == "{state}"' for state in statuses), + *(f"{scope} and {condition}" for _, condition in statuses), ], ) grid_item( diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index de807e647..a00190bc9 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py @@ -173,14 +173,18 @@ def test_any_req_in_report_version_false_if_no_requirement_matches() -> None: assert any_req_in_report_version([a, b], "v1.0") is False -def test_safety_pie_row_qm_has_no_inspected_slice() -> None: - """QM Needs are never formally inspected, so ``valid`` is their final slice.""" +def test_safety_pie_row_qm_counts_inspected_as_valid() -> None: + """QM has no inspected slice; ``valid_inspected`` QM Needs count as valid.""" safety_pie_row = vars(templates)["_SafetyPieRow"]() qm = safety_pie_row("Comp", "Requirements", "BASE", "QM", "QM", 1) asil = safety_pie_row("Comp", "Requirements", "BASE", "ASIL_B", "ASIL B", 1) - assert "valid_inspected" not in qm + assert ":labels: valid, invalid" in qm + assert ( + 'BASE and safety == "QM" and (status == "valid" or status == "valid_inspected")' + ) in qm + assert 'BASE and safety == "QM" and status == "invalid"' in qm assert ":labels: valid, valid_inspected, invalid" in asil assert 'BASE and safety == "ASIL_B" and status == "valid_inspected"' in asil assert ":class: safety-class-heading safety-asil-b" in asil diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index d3494fab7..ffaf6dc65 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -137,7 +137,7 @@
Status — QM - valid — agreed; QM needs are not formally inspected, so this is their final state + valid — agreed; QM needs are not formally inspected, so this is their final state (valid_inspected counts here too) invalid — not agreed; metamodel checks are skipped
diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index ac0e222f4..903bb56c3 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -130,7 +130,7 @@
Status — QM - valid — agreed; QM needs are not formally inspected, so this is their final state + valid — agreed; QM needs are not formally inspected, so this is their final state (valid_inspected counts here too) invalid — not agreed; metamodel checks are skipped
From 5b99c5085e00f2dc690e39c348d8c2bc69ff5255 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Thu, 15 Oct 2026 12:18:09 +0000 Subject: [PATCH 14/14] style: list valid_inspected before valid for ASIL B Puts the green "done" entry first in every status legend and pie, as it already is for QM, test coverage and reference coverage. --- src/extensions/score_sphinx_needs_templates/__init__.py | 6 +++--- .../tests/test_needs_templates.py | 3 ++- src/needs_templates/module_verification_report.need | 2 +- src/needs_templates/platform_verification_report.need | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index fc72014ca..d7f3f44d8 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -374,7 +374,7 @@ def __call__( missing: list[str] | None = None, ) -> str: scope = f'{base} and safety == "{safety}"' - # (slice label, status condition) per pie slice. + # (slice label, status condition) per pie slice; green "done" comes first. statuses = ( [ ("valid", '(status == "valid" or status == "valid_inspected")'), @@ -383,11 +383,11 @@ def __call__( if safety == "QM" else [ (state, f'status == "{state}"') - for state in ("valid", "valid_inspected", "invalid") + for state in ("valid_inspected", "valid", "invalid") ] ) status_colors = ( - "#37a12d, #ca2828" if safety == "QM" else "#f5c518, #37a12d, #ca2828" + "#37a12d, #ca2828" if safety == "QM" else "#37a12d, #f5c518, #ca2828" ) # docutils normalises ``:class:`` values, so avoid underscores in them. safety_css = safety.lower().replace("_", "-") diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index a00190bc9..641cde114 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py @@ -185,7 +185,8 @@ def test_safety_pie_row_qm_counts_inspected_as_valid() -> None: 'BASE and safety == "QM" and (status == "valid" or status == "valid_inspected")' ) in qm assert 'BASE and safety == "QM" and status == "invalid"' in qm - assert ":labels: valid, valid_inspected, invalid" in asil + assert ":labels: valid_inspected, valid, invalid" in asil + assert ":colors: #37a12d, #f5c518, #ca2828" in asil assert 'BASE and safety == "ASIL_B" and status == "valid_inspected"' in asil assert ":class: safety-class-heading safety-asil-b" in asil diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index ffaf6dc65..6df98553a 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -131,8 +131,8 @@
Status — ASIL B - valid — agreed, formal inspection still outstanding valid_inspected — agreed and formally inspected + valid — agreed, formal inspection still outstanding invalid — not agreed; metamodel checks are skipped
diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 903bb56c3..62efcf1e8 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -124,8 +124,8 @@
Status — ASIL B - valid — agreed, formal inspection still outstanding valid_inspected — agreed and formally inspected + valid — agreed, formal inspection still outstanding invalid — not agreed; metamodel checks are skipped