Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2e2599d
feat: split module report pies into ASIL and QM
antonkri Oct 13, 2026
b6eb13a
Merge branch 'main' into feat/report-split-asil-qm
antonkri Oct 13, 2026
493a6d9
feat(report): label safety class and show placeholders for empty pies
antonkri Oct 13, 2026
006a1f3
feat(report): split platform report pies into ASIL and QM
antonkri Oct 13, 2026
46dbf1c
style(platform report): show all three pies in one row
antonkri Oct 14, 2026
bb255e5
style(platform report): sort tables by safety class so ASIL B comes f…
antonkri Oct 14, 2026
a1b2251
feat(layout): put needpie legends below the pie
antonkri Oct 14, 2026
e2dda6a
revert(layout): drop the global needpie legend patch
antonkri Oct 14, 2026
2380ca3
feat: uniform pie size and a chart legend in both verification reports
antonkri Oct 14, 2026
c3a72cc
fix: centre the two-pie rows in the three-column raster
antonkri Oct 14, 2026
22ec27e
Merge remote-tracking branch 'origin/main' into feat/report-split-asi…
antonkri Oct 14, 2026
d480bc1
refactor: share safety_pie_row between the verification reports
antonkri Oct 15, 2026
2828fcc
test: cover the shared safety_pie_row helper
antonkri Oct 15, 2026
4443cb9
refactor: share id_filter_list and workproduct_rows as render helpers
antonkri Oct 15, 2026
fdd8096
fix: count valid_inspected QM Needs as valid in the status pie
antonkri Oct 15, 2026
480123a
Merge branch 'main' into feat/report-split-asil-qm
antonkri Oct 15, 2026
5b99c50
style: list valid_inspected before valid for ASIL B
antonkri Oct 15, 2026
5322f22
Merge branch 'main' into feat/report-split-asil-qm
antonkri Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/extensions/score_sphinx_needs_templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 181 additions & 0 deletions src/extensions/score_sphinx_needs_templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,178 @@ def __call__(self, ids: list[str], referenced: list[str]) -> dict[str, list[str]
_split_by_reference_callable = _SplitByReference()


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:
"""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 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
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}"'
# (slice label, status condition) per pie slice; green "done" comes first.
statuses = (
[
("valid", '(status == "valid" or status == "valid_inspected")'),
("invalid", 'status == "invalid"'),
]
if safety == "QM"
else [
(state, f'status == "{state}"')
for state in ("valid_inspected", "valid", "invalid")
]
)
status_colors = (
"#37a12d, #ca2828" if safety == "QM" else "#37a12d, #f5c518, #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",
"",
' <div class="empty-pie">',
f' <span class="empty-pie-title">{subject} {name} ({safety_label})</span>',
f' <span class="empty-pie-text">no {safety_label} {subject.lower()}</span>',
" </div>",
]
)
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(label for label, _ in statuses)}",
f":colors: {status_colors}",
":legend:",
"",
*(f"{scope} and {condition}" for _, condition 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_callable(referenced or [])}",
f"id in {_id_filter_list_callable(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()
Expand Down Expand Up @@ -386,6 +558,15 @@ 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
)
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,69 @@ 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_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 ":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_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


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('<div class="empty-pie">') == 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_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,
Expand Down
Loading
Loading