Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions doc/api/python/extensions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ Re-reads a document when its sphinx-needs TOML file or the needs it imports chan
.. automodule:: needs_config_state
:members:

``need_cards``
----------------

The type colors and the status and verdict classes of the zdocs need cards.

.. automodule:: need_cards
:members:

``xref_builder``
------------------

Expand Down
8 changes: 8 additions & 0 deletions doc/api/python/support-modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ Which optional need fields a consumer declared.
.. automodule:: needs_fields
:members:

``need_layouts``
------------------

Need layouts that a document's ``conf.py`` gets from the engine.

.. automodule:: need_layouts
:members:

``doxygen_parser``
--------------------

Expand Down
95 changes: 91 additions & 4 deletions doc/manual/reference/directives-and-roles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,25 @@ The verdicts:

The directive assesses a requirement if the run ran at least one of its
verifying test cases. It renders a summary of the run, a table of the
verdicts, and one section per verdict. Each need lists its symbols and
bodies. For each body, it gives the lines that each own test ran, and the
other tests that ran the body.
verdicts, and one section per verdict. A need has the title of its
requirement. Its body has:

* One sentence that says why the verdict is what it is ("The own tests run 1
of the 2 symbols that coverage can judge. Only other tests run
``k_inline``.").
* A table with one row per body: the function (``z_vrfy_k_sem_give``), its
form and place, the lines that the own tests ran of the lines that any test
of the run ran, and the number of other test cases that ran it. A
requirement with no body (``unresolved``, ``no-impl``) has no table.
* For each body that the own tests never run, the other tests that do: the
places where a ``@verifies`` can go. A test case is named once. The test
cases that ran the same lines are one group.
* The verifying tests, and the fields that the consumer does not declare
(``verdict``, ``evidence``, ``coverage_run``).

The table cells use the roles ``zd-adq-ok``, ``zd-adq-miss``, ``zd-adq-none``
and ``zd-adq-loc``. Each gives the text a class of the same name. The zdocs
need card (:ref:`need-layouts`) colors them.

The id of a need is ``ADQ-<run>/<requirement>``. ``testcoverage_id_prefix``
sets the prefix. The type, the link and the fields are roles, as for the other
Expand All @@ -385,7 +401,8 @@ directives:

Declare the type and the link in your ``needs_config.toml``. The directive
sets a field only if your ``needs_config.toml`` declares it (string fields).
The body of the need always shows the same information. The fields:
The body shows the verdict, the evidence and the run when they are not
fields. The fields:

``verdict``
One of the verdicts above.
Expand Down Expand Up @@ -456,6 +473,76 @@ stage-2 build (the XML cannot tell, since Doxygen writes a
unknown requirement" warning fails the Doxygen document through
``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS`` (:doc:`consumer-contract`).

.. _need-layouts:

Need layouts
------------

Not a directive: helpers for a document's ``conf.py``, which it imports from
``zdocs_conf``. Source: :external+zdocs-api:py:mod:`need_layouts`.

``need_layout()`` gives the zdocs need card: a head with the type, the ID, a
pill and the title; then the text of the need; then its fields and links.
``zdocs-needs.css`` styles a need whose layout name starts with ``zdocs``, so
give the layout such a name:

.. code-block:: python

from zdocs_conf import configure, need_layout

needs_layouts = {
"zdocs_requirement": need_layout(
fields=[("rtype", "Kind"), ("component", "Component")],
hide_backlinks=["covers"],
),
}
needs_default_layout = "zdocs_requirement"

``fields``
``(name, label)`` pairs, in the order to show. A field without a value
shows no row. Without ``fields``, the card shows every field that has a
value, under its own name; ``fields=[]`` shows the links only. A label is
plain words.
``hide_backlinks``
The links whose incoming side the card does not show. A requirement can
have hundreds of test results that cover it, and that list hides the rest
of the need.
``pill``
The field that the head shows as a pill: ``status`` (the default), or
``verdict`` for an adequacy need. The pill of a test result status and of
a verdict has the color of its value.

The rows stay in the sphinx-needs order (head, fields, text) in the HTML; the
CSS shows the text before the fields. So the PDF and a screen reader still
give the fields first.

The ``need_cards`` extension (loaded by ``zdocs_conf``) gives each card the
``color`` of its need type in ``needs_config.toml`` as the accent, and the
class ``zd-status-<status>`` / ``zd-verdict-<verdict>`` that colors the pill.
A type without a color stays grey.

The engine puts the references of a generated need (Depends on, Source, See
also, Declared in) in a ``need-refs`` container: the card shows them as one
block under the text.

A document with its own layout uses ``links_line()`` in place of
``<<meta_links_all()>>``:

.. code-block:: python

from zdocs_conf import configure, links_line

"meta": [
'<<meta("status", prefix="\\*\\*status:\\*\\* ")>>',
links_line(["result_of"]),
],

Use the link name from ``needs_config.toml`` (``covers``), not its
``incoming`` title (``covered by``). A name with a space or a quote is an
error. A name that is no link type of the document is not an error, but it
hides nothing. The outgoing side of the link stays. The links also stay in the
need data, so ``needs.json``, need tables and need flows still show them.

Doxylink prefixes
-----------------

Expand Down
18 changes: 18 additions & 0 deletions sphinx/_extensions/_tests/roots/test-needcards/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _extensions/

extensions = ["sphinx_needs", "need_cards"]
master_doc = "index"
exclude_patterns = ["_build"]
suppress_warnings = ["config.cache"]

needs_types = [
dict(directive="req", title="Requirement", prefix="REQ_", color="#FDEBD0", style="node"),
dict(directive="spec", title="Test Case", prefix="SPEC_", color="", style="node"),
]
10 changes: 10 additions & 0 deletions sphinx/_extensions/_tests/roots/test-needcards/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Need cards
==========

.. req:: A requirement
:id: REQ_1
:status: draft

.. spec:: A test case
:id: SPEC_1
:status: Passed / OK
84 changes: 82 additions & 2 deletions sphinx/_extensions/_tests/test_adequacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def _needs():
needs[f"IMPL-{sym}"] = {"id": f"IMPL-{sym}", "type": "impl", "title": sym,
"satisfies": reqs}
for r in REQS:
needs[r] = {"id": r, "type": "req", "title": r}
needs[r] = {"id": r, "type": "req", "title": f"Title of {r}"}
return {"current_version": "1.0", "versions": {"1.0": {"needs": needs}}}


Expand Down Expand Up @@ -472,7 +472,85 @@ def test_adequacy_rst_sets_the_declared_fields_under_the_consumers_names(tree):
assert ":layout:" not in rst
with_layout = tc.build_adequacy_rst("R-VRFY", results["R-VRFY"], run, names, (), "Q", "adq")
assert " :layout: adq" in with_layout and " :id: Q-cov-run/R-VRFY" in with_layout
assert f"own :need:`TSPEC-1`: lines {VRFY[0]}-{VRFY[0] + 2}" in rst
titled = tc.build_adequacy_rst("R-VRFY", results["R-VRFY"], run, names, title="Obj init")
assert titled[0] == ".. judgement:: Obj init"


def test_adequacy_rst_shows_the_undeclared_fields_in_the_body(tree):
run, results, _, _ = _assess(tree)
rst = "\n".join(tc.build_adequacy_rst("R-DEF", results["R-DEF"], run, fields={"verdict"}))
assert " **Verifying tests:** :need:`TSPEC-3`" in rst
assert "**Verdict:**" not in rst
assert " **Evidence:** passing" in rst
assert " **Coverage run:** cov-run" in rst
every = set(tc.ADEQUACY_FIELD_ROLES)
rst = "\n".join(tc.build_adequacy_rst("R-DEF", results["R-DEF"], run, fields=every))
assert "**Evidence:**" not in rst and "**Coverage run:**" not in rst


def test_adequacy_rst_gives_one_row_per_body(tree):
run, results, _, _ = _assess(tree)
rst = "\n".join(tc.build_adequacy_rst("R-VRFY", results["R-VRFY"], run))
# The function whose body was read, its form and place, the own lines of
# the lines any test ran, and the other test cases.
assert " * - ``z_impl_k_obj_init``" in rst
assert f":zd-adq-loc:`implementation, kernel/obj.c:{IMPL[0]}-{IMPL[1]}`" in rst
assert ":zd-adq-none:`no test ran it`" in rst
assert " * - ``z_vrfy_k_obj_init``" in rst
assert ":zd-adq-ok:`3 of 3 lines`" in rst
# The own tests run every body they should: no list of other tests.
assert "Other tests that run" not in rst


def test_adequacy_rst_names_the_other_tests_of_a_missed_body(tree):
run, results, _, _ = _assess(tree)
rst = "\n".join(tc.build_adequacy_rst("R-BROKEN", results["R-BROKEN"], run))
assert f":zd-adq-miss:`0 of {len(_span(PLAIN))} lines`" in rst
assert " - 3 test cases" in rst
assert " Other tests that run ``k_plain``:" in rst
# One group per set of lines. Groups of one size go by their first case.
a, b = PLAIN
assert (f" * lines {a}-{b}: :need:`TSPEC-3`\n"
f" * lines {a}, {a + 2}: :need:`TSPEC-8`\n"
f" * lines {a + 2}: :need:`TSPEC-10`") in rst


def test_adequacy_rst_without_a_body_has_no_table(tree):
run, results, _, _ = _assess(tree)
for req in ("R-MACRO", "R-NOIMPL"):
rst = "\n".join(tc.build_adequacy_rst(req, results[req], run))
assert "list-table" not in rst


@pytest.mark.parametrize("req, sentence", [
("R-VRFY", "The own tests run the satisfying symbol."),
("R-PARTIAL", "The own tests run 1 of the 2 symbols that coverage can judge. "
"Only other tests run ``k_inline``."),
("R-BROKEN", "Only other tests run ``k_plain``. The own tests never do."),
("R-UNATTR", "No test of the run runs a body of the satisfying symbols. "
"Coverage cannot judge the link."),
("R-MACRO", "``k_macro`` has no function body in the files that the directive searched."),
("R-NOIMPL", "No symbol satisfies this requirement."),
("R-NOCOV", "The run has no coverage data for the own tests."),
])
def test_the_summary_says_why(tree, req, sentence):
_, results, _, _ = _assess(tree)
assert tc.adequacy_summary(results[req]) == sentence


def test_the_summary_of_true_names_the_symbols_that_coverage_cannot_judge():
def body(sym, own, any_):
return {"sym": sym, "file": "k.c", "own": own, "any": any_}
two = {"verdict": "true", "impls": [body("a", 1, 1), body("b", 2, 2)]}
assert tc.adequacy_summary(two) == "The own tests run both satisfying symbols."
three = {"verdict": "true", "impls": [body("a", 1, 1), body("b", 1, 1), body("c", 1, 1)]}
assert tc.adequacy_summary(three) == "The own tests run all 3 satisfying symbols."
unjudged = {"verdict": "true", "impls": [body("a", 1, 1), body("b", 0, 0),
{"sym": "M", "file": None, "own": 0, "any": 0}]}
assert tc.adequacy_summary(unjudged) == (
"The own tests run the one symbol that coverage can judge. "
"Coverage cannot judge ``b`` and ``M``."
)


_CONF = """\
Expand Down Expand Up @@ -523,10 +601,12 @@ def test_directive_emits_one_adequacy_need_per_requirement(tree, make_app, tmp_p
assert five["assesses"] == ["R-PARTIAL"]
assert five["judged_symbols"] == "k_inline; k_plain"
assert five["coverage_run"] == "cov-run"
assert five["title"] == "Title of R-PARTIAL" # the requirement's title
log = app._warning.getvalue()
assert "WARNING" not in log, log
html = (Path(app.outdir) / "index.html").read_text()
assert "Verdict broken" in html and 'id="ADQ-cov-run/R-VRFY"' in html
assert '<span class="zd-adq-miss">' in html


def test_directive_reads_the_consumers_file_set(tree, make_app, tmp_path):
Expand Down
67 changes: 67 additions & 0 deletions sphinx/_extensions/_tests/test_need_cards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0

"""need_cards: the type colors and status classes of the need cards."""

from pathlib import Path

import pytest
from need_cards import CSS_NAME, status_class, type_colors_css

_ROOT = str(Path(__file__).parent / "roots" / "test-needcards")


def test_a_type_gets_its_color():
css = type_colors_css([{"directive": "req", "color": "#FDEBD0"}])
assert css == "table.need.need.need.need.needs_type_req { --zd-type: #FDEBD0; }\n"


@pytest.mark.parametrize("color", ["#abc", "#abcd", "#aabbcc", "#aabbccdd"])
def test_a_hex_color_of_a_css_length_gets_a_rule(color):
assert f"--zd-type: {color};" in type_colors_css([{"directive": "req", "color": color}])


@pytest.mark.parametrize(
"color", ["", None, "red; } body { x: y", "url(x)", "#12", "#12345", "#1234567", "#123456789"]
)
def test_a_type_without_a_css_color_gets_no_rule(color):
assert type_colors_css([{"directive": "req", "color": color}]) == "\n"


def test_the_class_has_the_type_name_without_white_space():
css = type_colors_css([{"directive": "my type", "color": "teal"}])
assert ".needs_type_mytype " in css


@pytest.mark.sphinx("html", srcdir=_ROOT)
def test_the_page_links_the_css_and_the_build_writes_it(app):
app.build()
assert CSS_NAME in (Path(app.outdir) / "index.html").read_text()
css = (Path(app.outdir) / "_static" / CSS_NAME).read_text()
assert "needs_type_req { --zd-type: #FDEBD0; }" in css
assert "needs_type_spec" not in css


@pytest.mark.parametrize(
"status, cls",
[("passed", "zd-status-passed"), ("Draft", "zd-status-draft"),
("Passed / OK", "zd-status-passed-ok"), ("", None), (None, None)],
)
def test_the_status_class(status, cls):
assert status_class(status) == cls


def test_the_class_of_a_verdict():
assert status_class("no-cov", "verdict") == "zd-verdict-no-cov"


@pytest.mark.sphinx("html", srcdir=_ROOT)
def test_each_need_table_gets_its_status_class(app):
app.build()
html = (Path(app.outdir) / "index.html").read_text()
req = html[html.rfind("<table", 0, html.index('id="REQ_1"')):html.index('id="REQ_1"')]
spec = html[html.rfind("<table", 0, html.index('id="SPEC_1"')):html.index('id="SPEC_1"')]
assert "zd-status-draft" in req
assert "zd-status-passed-ok" in spec
assert "zd-verdict-" not in req # no verdict field: no class
Loading