diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index 2d7a690..c53eec1 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -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`` ------------------ diff --git a/doc/api/python/support-modules.rst b/doc/api/python/support-modules.rst index 5481537..95c234b 100644 --- a/doc/api/python/support-modules.rst +++ b/doc/api/python/support-modules.rst @@ -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`` -------------------- diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 9336518..e8b52b3 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -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-/``. ``testcoverage_id_prefix`` sets the prefix. The type, the link and the fields are roles, as for the other @@ -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. @@ -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-`` / ``zd-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 +``<>``: + +.. code-block:: python + + from zdocs_conf import configure, links_line + + "meta": [ + '<>', + 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 ----------------- diff --git a/sphinx/_extensions/_tests/roots/test-needcards/conf.py b/sphinx/_extensions/_tests/roots/test-needcards/conf.py new file mode 100644 index 0000000..790684a --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-needcards/conf.py @@ -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"), +] diff --git a/sphinx/_extensions/_tests/roots/test-needcards/index.rst b/sphinx/_extensions/_tests/roots/test-needcards/index.rst new file mode 100644 index 0000000..e90225c --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-needcards/index.rst @@ -0,0 +1,10 @@ +Need cards +========== + +.. req:: A requirement + :id: REQ_1 + :status: draft + +.. spec:: A test case + :id: SPEC_1 + :status: Passed / OK diff --git a/sphinx/_extensions/_tests/test_adequacy.py b/sphinx/_extensions/_tests/test_adequacy.py index eb43f00..fbcad7d 100644 --- a/sphinx/_extensions/_tests/test_adequacy.py +++ b/sphinx/_extensions/_tests/test_adequacy.py @@ -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}}} @@ -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 = """\ @@ -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 '' in html def test_directive_reads_the_consumers_file_set(tree, make_app, tmp_path): diff --git a/sphinx/_extensions/_tests/test_need_cards.py b/sphinx/_extensions/_tests/test_need_cards.py new file mode 100644 index 0000000..f30ff61 --- /dev/null +++ b/sphinx/_extensions/_tests/test_need_cards.py @@ -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("`` and ``zd-verdict-`` to each need table that +has the field (``zd-status-passed``, ``zd-verdict-partial``), so that the card +can color the pill. The need data does not change. +""" + +import re +from pathlib import Path + +from docutils import nodes + +CSS_NAME = "zdocs-need-types.css" + +#: A CSS color as needs_config.toml gives it: #rgb(a) / #rrggbb(aa) or a name. +_COLOR = re.compile(r"^(#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})|[A-Za-z]+)$") + + +def type_colors_css(types): + """The CSS rules for ``types`` (the sphinx-needs ``needs_types`` list).""" + rules = [] + for need_type in types: + color = str(need_type.get("color") or "").strip() + if not _COLOR.match(color): + continue + # sphinx-needs: "needs_type_" + the type name without white space. + name = "".join(need_type["directive"].split()) + # One class more than the default in zdocs-needs.css, so the load + # order of the two files does not matter. + rules.append(f"table.need.need.need.need.needs_type_{name} {{ --zd-type: {color}; }}") + return "\n".join(rules) + "\n" + + +#: The fields that a card shows as a pill: the engine's status and verdict. +PILL_FIELDS = ("status", "verdict") + + +def status_class(value, field="status"): + """The class for ``value`` of ``field``: ``zd--`` and the value in + lower case, with each run of other characters than letters and digits as + one ``-``. ``None`` for no value.""" + slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return f"zd-{field}-{slug}" if slug else None + + +def _add_status_classes(app, doctree, docname): + from sphinx_needs.data import SphinxNeedsData + + needs = SphinxNeedsData(app.env).get_needs_view() + for table in doctree.findall(nodes.table): + if "need" not in table["classes"] or not table["ids"]: + continue + try: + need = needs[table["ids"][0]] + except KeyError: + continue + for field in PILL_FIELDS: + cls = status_class(need.get(field), field) + if cls and cls not in table["classes"]: + table["classes"].append(cls) + + +def _write(app, exception): + if exception is not None or app.builder.format != "html": + return + from sphinx_needs.config import NeedsSphinxConfig + + static = Path(app.outdir) / "_static" + static.mkdir(parents=True, exist_ok=True) + css = type_colors_css(NeedsSphinxConfig(app.config).types) + path = static / CSS_NAME + if not path.exists() or path.read_text() != css: + path.write_text(css) + + +def _add_css(app): + if app.builder.format == "html": + app.add_css_file(CSS_NAME) + + +def setup(app): + app.connect("builder-inited", _add_css) + # After sphinx-needs replaced each need node with its table + # (process_need_nodes, doctree-resolved at the default priority 500). + app.connect("doctree-resolved", _add_status_classes, priority=600) + # After the HTML builder copied the static files, which it does at the end. + app.connect("build-finished", _write) + return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/_extensions/test_coverage.py b/sphinx/_extensions/test_coverage.py index f1f373a..802256b 100644 --- a/sphinx/_extensions/test_coverage.py +++ b/sphinx/_extensions/test_coverage.py @@ -22,6 +22,7 @@ collect_links, line_ranges, load_coverage_run, + need_titles, ) from docutils import nodes from docutils.parsers.rst import Directive, directives @@ -48,9 +49,22 @@ "true": "The own tests run every symbol that coverage can judge.", } -#: At most this many other tests are named for one body. +#: At most this many test cases are named for one group of other tests. _OTHER_TESTS_SHOWN = 10 +#: The function whose body the directive reads, by body form. +_BODY_NAME = {"impl": "z_impl_{}", "vrfy": "z_vrfy_{}", "def": "{}", "inline": "{}"} +_BODY_FORM = {"impl": "implementation", "vrfy": "verifier", "def": "definition", + "inline": "static inline"} + +#: The roles of the body table cells: each gives an inline node with the +#: role name as its class, for ``zdocs-needs.css``. +CELL_ROLES = ("zd-adq-ok", "zd-adq-miss", "zd-adq-none", "zd-adq-loc") + +#: The engine fields that the body shows when the consumer does not declare them. +_BODY_FALLBACK = (("verdict", "Verdict"), ("evidence", "Evidence"), + ("coverage_run", "Coverage run")) + def _need_names(config): """The role->name mapping of every vocabulary the directive reads or writes.""" @@ -73,12 +87,6 @@ def _natural(uid): return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", uid)] -def _need_ref(key, run): - """A matrix key as the case needs it stands for, else as a literal.""" - cases = sorted(run.case_of_key.get(key, ())) - return ", ".join(f":need:`{c}`" for c in cases) if cases else f"``{key}``" - - def _symbol_hits(impls): """``"k_sem_init: own 11, any 11"`` per symbol, joined with ``"; "``.""" own, any_, order = Counter(), Counter(), [] @@ -90,10 +98,98 @@ def _symbol_hits(impls): return "; ".join(f"{s}: own {own[s]}, any {any_[s]}" for s in order) -def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", layout=""): +def _code_list(names): + """```a``, ``b`` and ``c```.""" + names = [f"``{n}``" for n in names] + return names[0] if len(names) == 1 else ", ".join(names[:-1]) + " and " + names[-1] + + +def _symbol_states(impls): + """``{symbol: (has a body, a test of the run ran it, an own test ran it)}``.""" + states = {} + for d in impls: + body, seen, hit = states.get(d["sym"], (False, False, False)) + states[d["sym"]] = (body or d["file"] is not None, seen or d["any"] > 0, + hit or d["own"] > 0) + return states + + +def adequacy_summary(res): + """One sentence (RST) that says why the verdict of ``res`` is what it is.""" + verdict = res["verdict"] + states = _symbol_states(res["impls"]) + judged = [s for s, (_, seen, _) in states.items() if seen] + missed = [s for s in judged if not states[s][2]] + not_judged = [s for s in states if s not in judged] + if verdict == "no-impl": + return "No symbol satisfies this requirement." + if verdict == "unresolved": + verb = "has" if len(states) == 1 else "have" + return (f"{_code_list(list(states))} {verb} no function body in the files " + f"that the directive searched.") + if verdict == "no-cov": + return "The run has no coverage data for the own tests." + if verdict == "unattributed": + return ("No test of the run runs a body of the satisfying symbols. Coverage " + "cannot judge the link.") + if verdict == "partial": + return (f"The own tests run {len(judged) - len(missed)} of the {len(judged)} " + f"symbols that coverage can judge. Only other tests run " + f"{_code_list(missed)}.") + if verdict == "broken": + return f"Only other tests run {_code_list(missed)}. The own tests never do." + # true + if not not_judged: + return {1: "The own tests run the satisfying symbol.", + 2: "The own tests run both satisfying symbols."}.get( + len(judged), f"The own tests run all {len(judged)} satisfying symbols.") + what = "the one symbol" if len(judged) == 1 else f"all {len(judged)} symbols" + return (f"The own tests run {what} that coverage can judge. Coverage cannot " + f"judge {_code_list(not_judged)}.") + + +def _own_cell(d): + if d["any"] == 0: + return ":zd-adq-none:`no test ran it`" + role = "zd-adq-ok" if d["own"] else "zd-adq-miss" + return f":{role}:`{d['own']} of {d['any']} lines`" + + +def _other_groups(d, run): + """The other tests of body ``d`` as ``[(line ranges, [case refs])]``. + + A case once, however many matrix keys it has; a key that no spec case has + stays a literal. The cases that ran the same lines form one group: the + largest group first, then by the first case. + """ + groups = {} + for key, hit in d["other_tests"].items(): + refs = groups.setdefault(line_ranges(hit), []) + for case in sorted(run.case_of_key.get(key, ())) or [None]: + ref = f":need:`{case}`" if case else f"``{key}``" + if ref not in refs: + refs.append(ref) + groups = [(r, sorted(c, key=_natural)) for r, c in groups.items()] + return sorted(groups, key=lambda g: (-len(g[1]), _natural(g[1][0]))) + + +def _other_count(d, run): + return len({c for key in d["other_tests"] for c in run.case_of_key.get(key, ()) or [key]}) + + +def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", layout="", + title=None): """RST lines for the adequacy need of ``req`` (``res`` from `adequacy.assess`). - ``layout``: the sphinx-needs layout of the need, if not empty. + ``layout``: the sphinx-needs layout of the need, if not empty. ``title``: + the title of the requirement; the need has it as its own title. Without it, + the title is ``Adequacy of ``. + + The body says why the verdict is what it is. A table gives each body of a + satisfying symbol: 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. For + a body that the own tests never run, the body names the other tests that + do. A field that the consumer does not declare is in the body too. """ values = { "verdict": res["verdict"], @@ -103,7 +199,7 @@ def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", "symbol_hits": _symbol_hits(res["impls"]), } lines = [ - f".. {_need_name(need_names, 'adequacy')}:: Adequacy of {req}", + f".. {_need_name(need_names, 'adequacy')}:: {title or f'Adequacy of {req}'}", f" :id: {adequacy_need_id(run.name, req, prefix)}", f" :{_need_name(need_names, 'assesses')}: {req}", ] @@ -112,60 +208,61 @@ def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", lines.append(f" :{_need_name(need_names, role)}: {values[role]}") if layout: lines.append(f" :layout: {layout}") - lines += [ - "", - f" Verdict ``{res['verdict']}``, evidence ``{res['evidence']}``, " - f"coverage run ``{run.name}``.", - "", - ] - cases = ", ".join(f":need:`{c}`" for c in res["cases"]) - lines += [f" Verifying test cases: {cases}.", ""] - if not res["impls"]: - lines += [" No symbol satisfies this requirement.", ""] - return lines - lines += [ - " .. list-table:: Satisfying symbols", - " :header-rows: 1", - " :widths: 20 10 30 10 10", - "", - " * - Symbol", - " - Body", - " - Location", - " - Own lines", - " - Any lines", - ] - for d in res["impls"]: - loc = f"``{d['file']}:{d['a']}-{d['b']}``" if d["file"] else "no body found" + lines += ["", f" {adequacy_summary(res)}", ""] + bodies = [d for d in res["impls"] if d["file"]] + if bodies: lines += [ - f" * - ``{d['sym']}``", - f" - {d['variant'] or '—'}", - f" - {loc}", - f" - {d['own']}", - f" - {d['any']}", + " .. list-table::", + " :header-rows: 1", + " :class: zd-adq-bodies", + "", + " * - Body", + " - Own tests ran", + " - Other tests", ] - lines.append("") - for d in res["impls"]: - if not d["file"] or not (d["own_tests"] or d["other_tests"]): + for d in bodies: + name = _BODY_NAME[d["variant"]].format(d["sym"]) + others = _other_count(d, run) + others = f"{others} test case{'s' if others != 1 else ''}" if others else "none" + lines += [ + f" * - ``{name}``", + "", + f" :zd-adq-loc:`{_BODY_FORM[d['variant']]}, " + f"{d['file']}:{d['a']}-{d['b']}`", + f" - {_own_cell(d)}", + f" - {others}", + ] + lines.append("") + no_body = [s for s, (body, _, _) in _symbol_states(res["impls"]).items() if not body] + if bodies and no_body: + lines += [f" No body found: {_code_list(no_body)}.", ""] + # Who runs the code that the own tests miss: where a @verifies can go. + for d in bodies: + if d["own"] or not d["other_tests"]: continue - lines += [f" ``{d['sym']}`` ({d['variant']}, ``{d['file']}:{d['a']}-{d['b']}``):", ""] - for case, hit in d["own_tests"].items(): - lines.append(f" * own :need:`{case}`: lines {line_ranges(hit)}") - others = list(d["other_tests"].items()) - for key, hit in others[:_OTHER_TESTS_SHOWN]: - lines.append(f" * other {_need_ref(key, run)}: lines {line_ranges(hit)}") - if len(others) > _OTHER_TESTS_SHOWN: - lines.append(f" * and {len(others) - _OTHER_TESTS_SHOWN} other tests") + name = _BODY_NAME[d["variant"]].format(d["sym"]) + lines += [f" Other tests that run ``{name}``:", ""] + for ranges, refs in _other_groups(d, run): + more = len(refs) - _OTHER_TESTS_SHOWN + tail = f" and {more} more" if more > 0 else "" + lines.append(f" * lines {ranges}: {', '.join(refs[:_OTHER_TESTS_SHOWN])}{tail}") lines.append("") + cases = ", ".join(f":need:`{c}`" for c in res["cases"]) + lines += [" .. container:: need-refs", "", f" **Verifying tests:** {cases}", ""] + for role, label in _BODY_FALLBACK: + if role not in fields: + lines += [f" **{label}:** {values[role]}", ""] return lines def build_coverage_rst( results, run, source, impl_loc, need_names=None, fields=(), prefix="ADQ", layout="", - impl_files=IMPL_PATTERNS, + impl_files=IMPL_PATTERNS, titles=None, ): """RST lines for the whole directive: run summary, distribution, needs by verdict. ``impl_files``: the body files that the run searched, for the summary. + ``titles``: the title of each requirement, for the title of its need. """ counts = Counter(r["verdict"] for r in results.values()) symbols = sorted({s for r in results.values() for s in r["symbols"]}) @@ -208,7 +305,8 @@ def build_coverage_rst( lines += [heading, "-" * len(heading), ""] for req in reqs: lines += build_adequacy_rst( - req, results[req], run, need_names, fields, prefix, layout + req, results[req], run, need_names, fields, prefix, layout, + (titles or {}).get(req), ) return lines @@ -299,6 +397,7 @@ def run(self): getattr(config, "testcoverage_id_prefix", "ADQ"), self.options.get("layout", "").strip(), impl_files, + need_titles(json_paths), ) dump_dir = getattr(config, "dump_generated_rst", "") if dump_dir: @@ -316,7 +415,13 @@ def run(self): return container.children +def _cell_role(name, rawtext, text, lineno, inliner, options=None, content=None): + return [nodes.inline(rawtext, text, classes=[name])], [] + + def setup(app): + for role in CELL_ROLES: + app.add_role(role, _cell_role) # The per-test coverage run directory (ZDOCS_COVERAGE_OUT): twister.json, # coverage/test_matrix.json and zephyr.sha. app.add_config_value("coverage_output_dir", "", "env") diff --git a/sphinx/_scripts/_tests/.gitignore b/sphinx/_scripts/_tests/.gitignore new file mode 100644 index 0000000..16fd48e --- /dev/null +++ b/sphinx/_scripts/_tests/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.pytest_cache/ +roots/*/_build/ diff --git a/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py b/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py new file mode 100644 index 0000000..f097fcf --- /dev/null +++ b/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py @@ -0,0 +1,44 @@ +# 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])) # _scripts/ + +from need_layouts import links_line, need_layout # noqa: E402 + +extensions = ["sphinx_needs"] +master_doc = "index" +exclude_patterns = ["_build"] + +needs_types = [ + dict(directive="req", title="Requirement", prefix="REQ_", color="#FDEBD0", style="node"), + dict(directive="spec", title="Test Case", prefix="SPEC_", color="#E2EFDA", style="node"), + dict(directive="res", title="Test Result", prefix="RES_", color="#DDEBF7", style="node"), +] +needs_id_regex = r"^[A-Za-z][A-Za-z0-9_-]+" +needs_links = { + "verifies": {"description": "verifies", "incoming": "verified by", "outgoing": "verifies"}, + "covers": {"description": "covers", "incoming": "covered by", "outgoing": "covers"}, +} +needs_build_json = True +suppress_warnings = ["config.cache"] + +needs_layouts = { + # The engine's card, without the incoming "covered by" links. + "zdocs_slim": need_layout(fields=[("status", "State")], hide_backlinks=["covers"]), + # A document's own layout, with the engine's links line in it. + "own": { + "grid": "simple", + "layout": { + "head": ['<> <>'], + "meta": [ + '<>', + links_line(["covers"]), + ], + }, + }, +} +needs_default_layout = "zdocs_slim" diff --git a/sphinx/_scripts/_tests/roots/test-needlayouts/index.rst b/sphinx/_scripts/_tests/roots/test-needlayouts/index.rst new file mode 100644 index 0000000..0ef366d --- /dev/null +++ b/sphinx/_scripts/_tests/roots/test-needlayouts/index.rst @@ -0,0 +1,23 @@ +Need layouts +============ + +.. req:: The requirement + :id: REQ_1 + :status: open + +.. req:: The requirement in the document's own layout + :id: REQ_2 + :status: open + :layout: own + +.. req:: The requirement in the sphinx-needs layout + :id: REQ_3 + :layout: clean + +.. spec:: A test case + :id: SPEC_1 + :verifies: REQ_1, REQ_2, REQ_3 + +.. res:: A test result + :id: RES_1 + :covers: REQ_1, REQ_2, REQ_3 diff --git a/sphinx/_scripts/_tests/test_need_layouts.py b/sphinx/_scripts/_tests/test_need_layouts.py new file mode 100644 index 0000000..d0620ea --- /dev/null +++ b/sphinx/_scripts/_tests/test_need_layouts.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""need_layouts: need layouts that a document's conf.py gets from the engine.""" + +import json +from pathlib import Path + +import pytest +from need_layouts import field_line, links_line, need_layout + +_ROOT = str(Path(__file__).parent / "roots" / "test-needlayouts") + + +# -- The generated layout strings ------------------------------------------------ + +def test_without_hidden_links_the_links_line_shows_all(): + assert links_line() == "<>" + + +def test_a_hidden_link_is_excluded_by_its_incoming_name(): + assert links_line(["covers", "trace"]) == ( + '<>' + ) + + +@pytest.mark.parametrize("name", ["covered by", 'x"]', "", "a>>b"]) +def test_a_name_that_is_no_link_name_is_an_error(name): + with pytest.raises(ValueError, match="no name"): + links_line([name]) + + +def test_the_card_has_the_head_the_fields_and_the_links(): + layout = need_layout(fields=[("rtype", "Kind")], hide_backlinks=["covers"]) + assert layout["grid"] == "simple" + assert layout["layout"]["head"] == [ + '<> <> <>', + '<>', + ] + assert layout["layout"]["meta"] == [ + '<>', + '<>', + ] + + +def test_without_fields_the_card_shows_all_but_the_status(): + meta = need_layout()["layout"]["meta"] + assert meta == ['<>', "<>"] + + +@pytest.mark.parametrize("label", ['Kind"', "a\\b", "**bold**", "<>", ""]) +def test_a_label_that_is_no_plain_words_is_an_error(label): + with pytest.raises(ValueError, match="plain words"): + field_line("rtype", label) + + +# -- In a build ---------------------------------------------------------------- + +def _need_html(app, need_id): + """The rendered need ``need_id`` in index.html.""" + html = (Path(app.outdir) / "index.html").read_text() + start = html.rfind("", start)] + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_a_hidden_backlink_is_not_shown(app): + app.build() + req = _need_html(app, "REQ_1") + assert "verified by" in req and "SPEC_1" in req + assert "covered by" not in req and "RES_1" not in req + assert "needs_layout_zdocs_slim" in req + assert 'State:' in req + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_a_documents_own_layout_can_use_the_links_line(app): + app.build() + req = _need_html(app, "REQ_2") + assert "state:" in req + assert "verified by" in req + assert "covered by" not in req + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_another_layout_still_shows_every_backlink(app): + app.build() + req = _need_html(app, "REQ_3") + assert "covered by" in req and "RES_1" in req + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_the_hidden_links_stay_in_the_need_data(app): + app.build() + needs = json.loads((Path(app.outdir) / "needs.json").read_text()) + req = next(iter(needs["versions"].values()))["needs"]["REQ_1"] + assert req["covers_back"] == ["RES_1"] + + +def test_the_pill_can_be_another_field(): + layout = need_layout(pill="verdict") + assert layout["layout"]["head"][0] == '<> <> <>' + assert layout["layout"]["meta"][0] == '<>' + + +def test_a_pill_that_is_no_field_name_is_an_error(): + with pytest.raises(ValueError, match="no name"): + need_layout(pill='status")>> <`__", + "**See also:** ``k_queue_get()``", + ] + assert rst.index("The queue keeps the order.") < rst.index(".. container:: need-refs") + + +def test_a_test_case_without_references_has_no_container(): + rst = rb.build_need_rst(_base_info(source_file="", doxygen_url=""), "kernel.queue") + assert "need-refs" not in rst + + +def test_the_references_of_a_symbol_are_in_the_container(): + info = { + "name": "k_queue_init", + "kind": "function", + "brief": "Initialize a queue.", + "doxygen_url": "", + "satisfies": [], + "depends_on": ["CONFIG_MULTITHREADING"], + "source_file": "queue.h (line 39)", + } + rst = rb.build_symbol_need_rst(info) + _, refs = _container(rst) + assert refs == [ + "**Depends on:** ``CONFIG_MULTITHREADING``", + "**Declared in:** ``queue.h (line 39)``", + ] + # Every body line is indented under the directive: nothing ends the need early. + body = rst.split("\n")[2:] + assert all(line == "" or line.startswith(" ") for line in body) diff --git a/sphinx/_scripts/adequacy.py b/sphinx/_scripts/adequacy.py index 5ca26a1..1c54b6d 100644 --- a/sphinx/_scripts/adequacy.py +++ b/sphinx/_scripts/adequacy.py @@ -79,6 +79,7 @@ "CoverageRun", "load_coverage_run", "collect_links", + "need_titles", "evidence", "adequacy", "assess", @@ -502,6 +503,16 @@ def collect_links(json_paths, need_names=None): return SpecLookup(entries), dict(verified_by), dict(satisfied_by), set(seen) +def need_titles(json_paths): + """``{need id: title}`` of the needs in the needs.json files (the first file wins).""" + titles = {} + for path in json_paths: + for nid, need in _needs_of(path).items(): + if need.get("title"): + titles.setdefault(nid, need["title"]) + return titles + + # --- verdicts -------------------------------------------------------------------------- diff --git a/sphinx/_scripts/need_layouts.py b/sphinx/_scripts/need_layouts.py new file mode 100644 index 0000000..5d392b8 --- /dev/null +++ b/sphinx/_scripts/need_layouts.py @@ -0,0 +1,98 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Need layouts that a document's ``conf.py`` gets from the engine. + +Not a Sphinx extension: a document calls :func:`need_layout` in its +``conf.py`` (re-exported by ``zdocs_conf``) and puts the result in its own +``needs_layouts``, under a name that starts with ``zdocs``:: + + 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" + +The layout is a card: a head with the type, the ID, the status 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`` (the HTML class +``needs_layout_``), and the ``need_cards`` extension gives each +need type its ``color`` from the needs configuration. + +The rows stay in the sphinx-needs order (head, fields, text); the CSS shows +the fields last. So the PDF and a screen reader still give the fields first. + +sphinx-needs shows every incoming link of a need. A requirement can have +hundreds of test results that cover it, and that list hides the rest of the +need. ``hide_backlinks`` names the links whose incoming side the layout does +not show. The links stay in the need data, so ``needs.json``, need tables and +need flows still have them. +""" + +import re + +#: A link or field name as sphinx-needs accepts it: an option name. +_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + +#: A field label: words, not RST or the quotes of the layout call. +_LABEL = re.compile(r"^[\w][\w .,()/-]*$") + +def _head(pill): + """The head: type, ID and the ``pill`` field on one line, the title on the next.""" + return [f'<> <> <>', '<>'] + + +def _check_names(what, names): + for name in names: + if not _NAME.match(name): + raise ValueError(f"{what}: {name!r} is no name; give the name, not its title") + + +def links_line(hide_backlinks=()): + """The layout line that shows the links of a need, without ``hide_backlinks``. + + For a document's own layout: put it in place of ``<>``. + """ + _check_names("hide_backlinks", hide_backlinks) + if not hide_backlinks: + return "<>" + exclude = ", ".join(f'"{name}_back"' for name in hide_backlinks) + return f"<>" + + +def field_line(name, label): + """The layout line for field ``name``, shown as ``label:``.""" + _check_names("fields", [name]) + if not _LABEL.match(label): + raise ValueError(f"fields: the label {label!r} of {name!r} must be plain words") + return f'<>' + + +def need_layout(fields=None, hide_backlinks=(), pill="status"): + """The zdocs need card. + + ``fields`` is a list of ``(name, label)`` pairs, in the order to show. If it + is ``None``, the card shows every field that has a value, under its own + name. + + ``hide_backlinks`` are link names from ``needs_config.toml`` + (``"covers"``), not their incoming titles (``"covered by"``). + + ``pill`` is the field that the head shows as a pill, next to the ID: the + ``status``, or the ``verdict`` of an adequacy need. ``zdocs-needs.css`` + colors the pill of a test result status and of a verdict. + """ + _check_names("pill", [pill]) + if fields is None: + meta = [f'<>'] + else: + meta = [field_line(name, label) for name, label in fields] + return { + "grid": "simple", + "layout": {"head": _head(pill), "meta": [*meta, links_line(hide_backlinks)]}, + } diff --git a/sphinx/_scripts/rst_builders.py b/sphinx/_scripts/rst_builders.py index 44319ff..e320450 100644 --- a/sphinx/_scripts/rst_builders.py +++ b/sphinx/_scripts/rst_builders.py @@ -91,10 +91,25 @@ def _depends_on_rst(info, depends_field): return [], [] options = [f" :depends_on: {'; '.join(conditions)}"] if depends_field else [] label = info.get("depends_label") or "Depends on" - body = [f" **{label}:** " + "; ".join(f"``{c}``" for c in conditions), ""] + body = [f"**{label}:** " + "; ".join(f"``{c}``" for c in conditions)] return options, body +def _refs_rst(paragraphs): + """The need body lines for the reference paragraphs (Depends on, Source, See also). + + They go into a ``container`` with the class ``need-refs``, so that a need + layout can set them apart from the text (zdocs-needs.css). The text of each + paragraph does not change. No paragraphs give no lines. + """ + if not paragraphs: + return [] + lines = [" .. container:: need-refs", ""] + for paragraph in paragraphs: + lines += [f" {paragraph}", ""] + return lines + + def build_need_rst( info, suite_name, module_path="", suite_title="", need_names=None, depends_field=False, id_scope=None, @@ -162,18 +177,14 @@ def build_need_rst( lines.append(f" {sline}" if sline else "") lines.append("") - lines += depends_body - + refs = list(depends_body) if source_file and doxygen_url: - lines.append(f" **Source:** `{source_file} <{doxygen_url}>`__") - lines.append("") + refs.append(f"**Source:** `{source_file} <{doxygen_url}>`__") elif source_file: - lines.append(f" **Source:** {source_file}") - lines.append("") - + refs.append(f"**Source:** {source_file}") if see_rst: - lines.append(f" {see_rst}") - lines.append("") + refs.append(see_rst) + lines += _refs_rst(refs) return "\n".join(lines) @@ -252,15 +263,12 @@ def build_procedure_need_rst( lines.append(f" :``{pname}``: {pdesc}") lines.append("") + refs = [] if source_file and doxygen_url: - lines.append( - f" **Source:** :c:func:`{name}` — `{source_file} <{doxygen_url}>`__" - ) - lines.append("") - + refs.append(f"**Source:** :c:func:`{name}` — `{source_file} <{doxygen_url}>`__") if see_rst_str: - lines.append(f" {see_rst_str}") - lines.append("") + refs.append(see_rst_str) + lines += _refs_rst(refs) return "\n".join(lines) @@ -388,9 +396,10 @@ def build_symbol_need_rst(info, need_names=None, depends_field=False): if info["doxygen_url"]: head = f"`{kind.capitalize()} {name} <{info['doxygen_url']}>`__" lines += [f" {head}" + (f" — {info['brief']}" if info["brief"] else ""), ""] - lines += depends_body + refs = list(depends_body) if info["source_file"]: - lines += [f" **Declared in:** ``{info['source_file']}``", ""] + refs.append(f"**Declared in:** ``{info['source_file']}``") + lines += _refs_rst(refs) return "\n".join(lines) diff --git a/sphinx/_static/zdocs-needs.css b/sphinx/_static/zdocs-needs.css new file mode 100644 index 0000000..7effdd5 --- /dev/null +++ b/sphinx/_static/zdocs-needs.css @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2026 inovex GmbH + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * The zdocs need card (need_layouts.need_layout()). + * + * Only for a need whose layout name starts with "zdocs": sphinx-needs gives + * the table the class needs_layout_. A document's own layouts keep the + * sphinx-needs look. + * + * The rules start with table.need.need.need, as sphinx-needs' own do, so that + * they win over the theme without !important. + * + * Each need type has its color in --zd-type. The need_cards extension + * sets it from the "color" of the type in the needs configuration + * (zdocs-need-types.css). The other colors come from it. + */ + +table.need.need.need[class*=" needs_layout_zdocs"] { + --zd-type: #eeeeee; + --zd-accent: #666666; + --zd-accent: oklch(from var(--zd-type) 0.52 calc(c * 4 + 0.02) h); + --zd-border: #d5d9de; + --zd-label: #5f6670; + --zd-text: #1d2329; + --zd-mono: SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + + display: block; + margin: 1.5em 0; + background: #fff; + border: 1px solid var(--zd-border); + border-left: 5px solid var(--zd-accent); + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + overflow: hidden; +} + +/* + * Show the head, the text, then the fields. The rows stay in the sphinx-needs + * order (head, meta, content) in the HTML, so the PDF does not change; the + * table body becomes a flex column and the meta row moves to the end. + */ +table.need.need.need[class*=" needs_layout_zdocs"] > tbody { + display: flex; + flex-direction: column; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr, +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr > td { + display: block; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.head { order: 0; } +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content { order: 1; } +/* A need without text (most test results) has no text row. */ +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content:not(:has(> td > *)) { + display: none; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta { order: 2; } + +/* No theme row stripes in a card */ +.rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr > td { + background: transparent; +} + +/* -- Head: type, ID and status; then the title ------------------------------ */ + +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.head { + background: color-mix(in srgb, var(--zd-type) 55%, #fff); +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.head > td:not([rowspan]) { + padding: 0.7em 1.25em 0.75em; + border-bottom: 1px solid color-mix(in srgb, var(--zd-type) 70%, var(--zd-border)); +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head .needs_type_name { + margin-right: 0.6em; + color: var(--zd-accent); + font-size: 0.75em; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head .needs-id a { + color: #2b3a4a; + font-family: var(--zd-mono); + font-size: 0.85em; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head :is(.needs_status, .needs_verdict) { + display: inline-block; + margin-left: 0.7em; + padding: 0.05em 0.6em; + border: 1px solid var(--zd-accent); + border-radius: 999px; + color: var(--zd-accent); + font-size: 0.72em; + font-weight: 600; + text-transform: capitalize; /* "draft" and "Draft" look the same */ + vertical-align: 0.1em; +} +/* + * The status of a test result and the verdict of an adequacy need (the + * need_cards extension adds zd-status- and zd-verdict-): + * green good, red failing, amber a gap, grey not judged or not run. Other + * statuses, like the "Draft" of a requirement, keep the type accent. + */ +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-status-passed, .zd-verdict-true) tr.head :is(.needs_status, .needs_verdict) { + border-color: #1a7f37; + background: #dafbe1; + color: #116329; +} +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-status-failed, .zd-status-error, .zd-verdict-broken) tr.head :is(.needs_status, .needs_verdict) { + border-color: #cf222e; + background: #ffebe9; + color: #a40e26; +} +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-status-skipped, .zd-status-filtered, .zd-status-blocked, .zd-status-not-run, .zd-verdict-unattributed, .zd-verdict-unresolved, .zd-verdict-no-cov) tr.head :is(.needs_status, .needs_verdict) { + border-color: #8c959f; + background: #eaeef2; + color: #424a53; +} +/* A gap, not a failure: some of the code, or no code at all. */ +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-verdict-partial, .zd-verdict-no-impl) tr.head :is(.needs_status, .needs_verdict) { + border-color: #bf8700; + background: #fff8c5; + color: #7d4e00; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head .needs_title { + display: block; + margin-top: 0.2em; + color: var(--zd-text); + font-size: 1.2em; + font-weight: 600; + line-height: 1.3; +} + +/* -- The text of the need ----------------------------------------------------- */ + +/* + * The size, line height and paragraph gap of the page text around the card. + * The rtd theme sets 90 % / .9rem and a 1rem line height in every table cell + * (html.writer-html5 .rst-content table.docutils td > p), and a need is a table. + */ +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td { + padding: 1.1rem 1.25rem 0.5rem; + font-size: 1rem; + line-height: 1.5rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td p { + margin: 0 0 0.75rem; + font-size: 1rem; + line-height: 1.5rem; +} + +/* The brief that zdocs writes first in a test case (rst-class need-brief) */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.content p.need-brief { + color: var(--zd-text); + font-weight: 600; +} + +/* + * A paragraph right before a list ("Test steps:", "Expected result:") is its + * heading. Not the first paragraph: there it is the introduction of the text + * (a test procedure starts with "... performs the following sequence:"). + */ +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td > p:not(:first-child):has(+ ul, + ol) { + margin: 1rem 0 0.25rem; + color: var(--zd-text); + font-weight: 600; +} + +/* Lists: inside the text column, one line height apart */ +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td :is(ul, ol) { + margin: 0 0 0.75rem 1.4rem; + padding: 0; +} +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td ul > li { + list-style: disc; +} +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td li { + margin: 0.1rem 0; + line-height: 1.5rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td li > p { + margin: 0; + line-height: 1.5rem; +} + +/* + * A field list in the text (the parameters of a test procedure): the name in + * code type, the description beside it, without the theme's boxes. + */ +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 1em; + margin: 0 0 0.75rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list > dt { + margin: 0; + padding: 0; + border: none; + background: none; + font-weight: 400; + line-height: 1.5rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list > dt code { + padding: 0; + border: none; + background: none; + color: var(--zd-text); + font-family: var(--zd-mono); + font-size: 0.9em; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list > dd { + margin: 0; + padding: 0; +} + +/* + * The references that zdocs writes at the end of a test case (Depends on, + * Source, See also; container need-refs): a label column like the fields. + */ +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] div.need-refs { + margin: 1rem 0 0.25rem; + padding-top: 0.6rem; + border-top: 1px dashed #e1e4e8; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td div.need-refs > p { + margin: 0.15rem 0; + padding-left: 9.5em; + text-indent: -9.5em; + font-size: 0.9rem; + line-height: 1.6; +} +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] div.need-refs > p > strong:first-child { + display: inline-block; + width: calc(9.5em - 0.21em); /* the space after the label follows */ + color: var(--zd-label); + font-weight: 400; + text-indent: 0; +} + +/* -- Fields and links: one label/value grid ------------------------------------ */ + +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta > td { + background: #fafbfc; + font-size: 0.9rem; + line-height: 1.6; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta > td:not([rowspan]) { + padding: 0.5rem 1.25rem 0.6rem; + border-top: 1px solid #e6e9ec; + border-bottom: none; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line-block { + margin: 0; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line { + min-height: 0; + margin: 0; + padding: 0; +} +/* The wrappers around a row take no box of their own. */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(div.line), +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(div.line) > span, +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(div.line) > span > span, +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line span:has(.needs_label) { + display: contents; +} +/* + * A row is a line without lines in it: a field (label and data spans) or a + * link line (the text "Verified by:" and a span with the links). + */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:not(:has(div.line)) { + display: grid; + grid-template-columns: 8.5em 1fr; + column-gap: 1em; + padding: 0.1em 0; + color: var(--zd-label); + line-height: 1.6; +} +/* A need without fields and links (a test procedure) has no fields row. */ +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta:not(:has(.needs_data, a, .needs_dead_link)) { + display: none; +} +/* A field without a value is a line with one empty span: no row. */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(> span:empty:only-child) { + display: none; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta .needs_data, +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:not(:has(div.line)) > span:not(:has(.needs_label)) { + color: var(--zd-text); +} +/* Links, and links to a need that does not exist (a span, not an a) */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta :is(a, .needs_dead_link) { + font-family: var(--zd-mono); + font-size: 0.95em; + white-space: nowrap; +} +/* The arrow of a link into another document: every second link has one. */ +table.need.need.need[class*=" needs_layout_zdocs"] a.external_link::after { + content: none; +} + +/* + * The engine's own fields that hold code or names from the build: the test + * function, module path and suite; the platform, scenario and twister ID. + */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta :is(.needs_test_function, .needs_test_module, .needs_suite, .needs_platform, .needs_scenario, .needs_twister_id) .needs_data { + font-family: var(--zd-mono); + font-size: 0.95em; +} + +/* + * The body table of an adequacy need (testcoverage): one row per body of a + * satisfying symbol. The roles zd-adq-ok / -miss / -none give the lines that + * the own tests ran as a chip; zd-adq-loc is the form and place of the body. + */ +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies { + width: 100%; + margin: 0 0 0.75rem; + border: none; + border-collapse: collapse; + font-size: 0.9rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies colgroup { + display: none; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies :is(th, td) { + padding: 0.35rem 0.75rem 0.35rem 0; + border: none; + border-bottom: 1px solid var(--zd-border); + background: transparent; + vertical-align: top; + white-space: normal; + font-size: inherit; + line-height: 1.35; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies th { + border-bottom-width: 2px; + color: var(--zd-label); + font-weight: 600; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies :is(th, td):first-child { + width: 55%; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies :is(th, td):nth-child(2) { + width: 22%; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies td p { + margin: 0; + font-size: inherit; + line-height: inherit; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies code { + padding: 0; + border: none; + background: none; + color: var(--zd-text); + font-size: 0.95em; + font-weight: 600; +} +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-loc { + color: var(--zd-label); + font-family: var(--zd-mono); + font-size: 0.85em; +} +table.need.need.need[class*=" needs_layout_zdocs"] :is(.zd-adq-ok, .zd-adq-miss, .zd-adq-none) { + display: inline-block; + padding: 0 0.5em; + border-radius: 999px; + font-size: 0.9em; + white-space: nowrap; +} +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-ok { background: #e3f3e6; color: #1e6b33; } +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-miss { background: #fbe3e3; color: #a12828; } +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-none { background: #eceef1; color: #5f6670; } diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index cdfde6b..32599f4 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -51,6 +51,7 @@ import docrefs # noqa: E402 (needs the sys.path above) from doc_control import latex_escape # noqa: E402 (same) +from need_layouts import links_line, need_layout # noqa: E402,F401 (for conf.py) #: Defines the macro ``doc_control`` emits for the PDF-only sign-off block. #: @@ -134,6 +135,10 @@ def configure( # Also when the needs it imports from a peer's needs.json change: # else a page does not show a new incoming link from the peer. "needs_config_state", + # For the zdocs need cards (zdocs-needs.css): the color of each need + # type, from the needs configuration (one small CSS file), and a class + # for the status of each need. + "need_cards", # The `doc_control` directive: the controlled-document header (owner, # classification, approval dates, version). Registers `signature_section` # and `releaselevel` as config values, so it must be loaded even by @@ -417,7 +422,7 @@ def configure( namespace["html_static_path"] = [str(ZDOCS_DOC_DIR / "_static")] + [ str(p) for p in (static_path or []) ] - namespace["html_css_files"] = ["zdocs-sphinx.css"] + list(css_files or []) + namespace["html_css_files"] = ["zdocs-sphinx.css", "zdocs-needs.css"] + list(css_files or []) if refs is not None: # Consumed by the cross-document navigation in the page template. namespace["html_context"] = {"reference_groups": refs.reference_groups}