Files
computer-playbook/tests/unit/python/cli/meta/roles/design/test_queue.py
T
kevinveenbirkenbachandClaude Opus 5.5 25a1f08ab7 feat(design): leave the design queue by switching the design service off
The design queue skipped a hand-kept UI_LESS_ROLES list in
cli/meta/roles/design. The decision now lives in each role: a web-app- or
web-svc- role without a UI to design sets design.enabled: false in its
meta/services.yml, and design_disabled() reads that flag. web-svc-design,
the provider, stays out by its entity name.

A new lint, test_design_off_reason.py, requires a design entry in every
web-app- and web-svc- role and, where the entry is switched off, a
"# nocheck: design-off  Reason: <text>" on the design: line or the comment
lines directly above it. The rule is in the suppression catalog, and
requirement 037 decision 13 describes it.

Twelve roles switch design off with a reason checked against their code:
asset and simpleicons were already off; file, onlyoffice, bridgy-fed and
mig were on and no longer receive the shared CSS and design script; cdn,
collabora, coturn, legal, mirror and xmpp had no entry. The four that were
on drop their bond and their design variant overrides, which the
disabled-bond and variants-static-override lints reject on a literal false.
The queue lists 77 candidates; HEAD's list also counted bridgy-fed, mig,
collabora and onlyoffice. Cosmos diagrams and the integration matrix are
regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:09:33 +02:00

596 lines
23 KiB
Python

"""Unit tests for the `cli.meta.roles.design` work queue."""
from __future__ import annotations
import io
import json
import os
import subprocess
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import replace
from pathlib import Path
from typing import ClassVar
from unittest.mock import patch
from cli.meta.roles.design import (
BASE_PATHS,
SPEC_FILE,
DesignCommit,
GitError,
RoleFacts,
build_queue,
candidate_roles,
collect_facts,
not_due,
reason_of,
repository_root,
state_of,
version_gap,
)
from cli.meta.roles.design.__main__ import main as cli_main
from utils.cache.yaml import dump_yaml
from utils.roles.mapping import ROLE_FILE_META_SERVICES
NEVER_DESIGNED = RoleFacts(
role="web-app-fresh",
created=100,
changed=100,
designed=None,
in_review=False,
base_changed=False,
designed_version=None,
current_version="1.0.0",
)
DESIGNED = replace(
NEVER_DESIGNED,
role="web-app-designed",
designed=DesignCommit(sha="a" * 40, timestamp=150),
designed_version="1.0.0",
)
GIT_IDENTITY = {
"GIT_AUTHOR_NAME": "test",
"GIT_AUTHOR_EMAIL": "test@example.invalid",
"GIT_COMMITTER_NAME": "test",
"GIT_COMMITTER_EMAIL": "test@example.invalid",
}
def _isolate_git(case: unittest.TestCase) -> None:
"""Detach git from the surrounding repository and user for one test.
Inside a git hook the environment carries ``GIT_DIR`` and ``GIT_INDEX_FILE``
of the repository being committed, which would redirect every git call of
the test into that repository. A global ``commit.gpgsign`` would make the
fixture commits wait for a signing key.
Args:
case: Test whose environment is replaced until its cleanup runs.
"""
scoped = subprocess.run(
["git", "rev-parse", "--local-env-vars"],
capture_output=True,
text=True,
check=True,
).stdout.split()
env = {key: value for key, value in os.environ.items() if key not in scoped}
env.update(GIT_IDENTITY, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1")
patcher = patch.dict(os.environ, env, clear=True)
patcher.start()
case.addCleanup(patcher.stop)
class TestVersionGap(unittest.TestCase):
def test_leading_v_is_ignored(self) -> None:
self.assertEqual(version_gap("v1.2.3", "1.4.0"), (0, 2, 3))
def test_short_version_is_padded(self) -> None:
self.assertEqual(version_gap("2.0", "1.9.9"), (1, 9, 9))
def test_suffix_behind_the_number_is_ignored(self) -> None:
self.assertEqual(version_gap("17-3.5", "18-3.5"), (1, 0, 0))
def test_different_non_numeric_versions_have_the_minimal_gap(self) -> None:
self.assertEqual(version_gap("latest", "stable"), (0, 0, 1))
def test_equal_versions_have_no_gap(self) -> None:
self.assertEqual(version_gap("1.2.3", "1.2.3"), (0, 0, 0))
def test_equal_non_numeric_versions_have_no_gap(self) -> None:
self.assertEqual(version_gap("latest", "latest"), (0, 0, 0))
def test_missing_version_has_the_minimal_gap(self) -> None:
self.assertEqual(version_gap(None, "1.2.3"), (0, 0, 1))
def test_two_missing_versions_have_no_gap(self) -> None:
self.assertEqual(version_gap(None, None), (0, 0, 0))
def test_change_behind_the_number_has_the_minimal_gap(self) -> None:
self.assertEqual(version_gap("2.4.0p32", "2.4.0p33"), (0, 0, 1))
def test_change_in_the_fourth_component_has_the_minimal_gap(self) -> None:
self.assertEqual(version_gap("1.2.3.4", "1.2.3.9"), (0, 0, 1))
class TestStateOf(unittest.TestCase):
def test_role_without_design_commit_is_new(self) -> None:
self.assertEqual(state_of(NEVER_DESIGNED), "new")
def test_uncommitted_spec_is_in_review(self) -> None:
self.assertEqual(state_of(replace(NEVER_DESIGNED, in_review=True)), "review")
def test_review_outranks_a_stale_design(self) -> None:
facts = replace(DESIGNED, in_review=True, base_changed=True)
self.assertEqual(state_of(facts), "review")
def test_base_change_alone_makes_a_design_stale(self) -> None:
self.assertEqual(state_of(replace(DESIGNED, base_changed=True)), "stale")
def test_version_gap_alone_makes_a_design_stale(self) -> None:
self.assertEqual(state_of(replace(DESIGNED, current_version="1.0.1")), "stale")
def test_unchanged_design_is_current(self) -> None:
self.assertEqual(state_of(DESIGNED), "current")
class TestReasonOf(unittest.TestCase):
def test_new_role_was_never_designed(self) -> None:
self.assertEqual(reason_of(NEVER_DESIGNED), "never designed")
def test_version_gap_names_both_versions(self) -> None:
facts = replace(DESIGNED, current_version="2.0.0")
self.assertEqual(reason_of(facts), "version 1.0.0 -> 2.0.0")
def test_base_change_is_named(self) -> None:
facts = replace(DESIGNED, base_changed=True)
self.assertEqual(reason_of(facts), "design base changed")
def test_version_gap_and_base_change_are_joined(self) -> None:
facts = replace(DESIGNED, current_version="2.0.0", base_changed=True)
self.assertEqual(
reason_of(facts), "version 1.0.0 -> 2.0.0, design base changed"
)
def test_undeclared_version_is_shown_as_none(self) -> None:
facts = replace(DESIGNED, designed_version=None, current_version="2.0.0")
self.assertEqual(reason_of(facts), "version none -> 2.0.0")
class TestBuildQueue(unittest.TestCase):
@staticmethod
def _roles(facts: list[RoleFacts]) -> list[str]:
return [entry.role for entry in build_queue(facts)]
def test_new_roles_come_before_stale_roles(self) -> None:
stale = replace(
DESIGNED, role="web-app-a", current_version="9.0.0", changed=999
)
new = replace(NEVER_DESIGNED, role="web-app-z", created=1, changed=1)
self.assertEqual(self._roles([stale, new]), ["web-app-z", "web-app-a"])
def test_new_roles_are_ordered_newest_first(self) -> None:
older = replace(NEVER_DESIGNED, role="web-app-a", created=100, changed=900)
newer = replace(NEVER_DESIGNED, role="web-app-b", created=200, changed=200)
self.assertEqual(self._roles([older, newer]), ["web-app-b", "web-app-a"])
def test_stale_roles_are_ordered_by_version_gap_with_base_only_last(self) -> None:
patch_gap = replace(DESIGNED, role="web-app-patch", current_version="1.0.9")
major_gap = replace(DESIGNED, role="web-app-major", current_version="2.0.0")
base_only = replace(
DESIGNED, role="web-app-base", base_changed=True, changed=999
)
minor_gap = replace(DESIGNED, role="web-app-minor", current_version="1.5.0")
self.assertEqual(
self._roles([patch_gap, major_gap, base_only, minor_gap]),
["web-app-major", "web-app-minor", "web-app-patch", "web-app-base"],
)
def test_tie_falls_to_the_most_recently_changed_role(self) -> None:
quiet = replace(DESIGNED, role="web-app-a", base_changed=True, changed=100)
busy = replace(DESIGNED, role="web-app-b", base_changed=True, changed=200)
self.assertEqual(self._roles([quiet, busy]), ["web-app-b", "web-app-a"])
def test_tie_on_the_change_time_falls_to_the_role_name(self) -> None:
second = replace(NEVER_DESIGNED, role="web-app-b")
first = replace(NEVER_DESIGNED, role="web-app-a")
self.assertEqual(self._roles([second, first]), ["web-app-a", "web-app-b"])
def test_role_without_commit_sorts_behind_committed_new_roles(self) -> None:
uncommitted = replace(
NEVER_DESIGNED, role="web-app-a", created=None, changed=None
)
committed = replace(NEVER_DESIGNED, role="web-app-b")
self.assertEqual(
self._roles([uncommitted, committed]), ["web-app-b", "web-app-a"]
)
def test_review_and_current_roles_are_left_out(self) -> None:
review = replace(NEVER_DESIGNED, role="web-app-review", in_review=True)
self.assertEqual(
self._roles([review, DESIGNED, NEVER_DESIGNED]), ["web-app-fresh"]
)
def test_ranks_count_from_one(self) -> None:
entries = build_queue([replace(DESIGNED, base_changed=True), NEVER_DESIGNED])
self.assertEqual([entry.rank for entry in entries], [1, 2])
class TestNotDue(unittest.TestCase):
def test_review_roles_come_before_current_roles_without_a_rank(self) -> None:
review = replace(NEVER_DESIGNED, role="web-app-review", in_review=True)
entries = not_due([DESIGNED, NEVER_DESIGNED, review])
self.assertEqual(
[(entry.rank, entry.role, entry.state) for entry in entries],
[
(None, "web-app-review", "review"),
(None, "web-app-designed", "current"),
],
)
class TestCandidateRoles(unittest.TestCase):
CONSUMER: ClassVar[dict[str, bool]] = {"enabled": True}
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.roles_dir = Path(tmp.name)
def _role(self, name: str, services: dict[str, dict]) -> None:
dump_yaml(self.roles_dir / name / ROLE_FILE_META_SERVICES, services)
def test_ui_roles_inside_the_tested_envelope_are_candidates(self) -> None:
self._role(
"web-app-demo", {"demo": {"lifecycle": "beta"}, "design": self.CONSUMER}
)
self._role("web-svc-demo", {"demo": {"lifecycle": "beta"}})
self.assertEqual(
candidate_roles(self.roles_dir), ["web-app-demo", "web-svc-demo"]
)
def test_role_without_design_service_is_a_candidate(self) -> None:
self._role("web-app-demo", {"demo": {"lifecycle": "beta"}})
self.assertEqual(candidate_roles(self.roles_dir), ["web-app-demo"])
def test_role_outside_the_tested_envelope_is_skipped(self) -> None:
self._role(
"web-app-demo", {"demo": {"lifecycle": "eol"}, "design": self.CONSUMER}
)
self.assertEqual(candidate_roles(self.roles_dir), [])
def test_role_without_lifecycle_is_skipped(self) -> None:
self._role("web-app-demo", {"demo": {}, "design": self.CONSUMER})
self.assertEqual(candidate_roles(self.roles_dir), [])
def test_role_that_switches_design_off_is_skipped(self) -> None:
self._role(
"web-svc-cdn", {"cdn": {"lifecycle": "beta"}, "design": {"enabled": False}}
)
self.assertEqual(candidate_roles(self.roles_dir), [])
def test_templated_design_flag_keeps_the_role(self) -> None:
flag = "{{ 'web-svc-design' in group_names }}"
self._role(
"web-app-demo", {"demo": {"lifecycle": "beta"}, "design": {"enabled": flag}}
)
self.assertEqual(candidate_roles(self.roles_dir), ["web-app-demo"])
def test_design_provider_is_skipped(self) -> None:
self._role("web-svc-design", {"design": {"lifecycle": "beta"}})
self.assertEqual(candidate_roles(self.roles_dir), [])
def test_roles_outside_the_ui_categories_are_skipped(self) -> None:
for name in ("svc-db-demo", "drv-demo", "web-opt-rdr-demo", "update"):
self._role(name, {"demo": {"lifecycle": "beta"}, "design": self.CONSUMER})
self._role("sys-front-inj-design", {"design": {"lifecycle": "beta"}})
self.assertEqual(candidate_roles(self.roles_dir), [])
def test_directory_without_services_file_is_skipped(self) -> None:
(self.roles_dir / "web-app-demo").mkdir()
self.assertEqual(candidate_roles(self.roles_dir), [])
class TestCollectFacts(unittest.TestCase):
ROLE: ClassVar[str] = "web-app-demo"
SPEC: ClassVar[str] = f"roles/{ROLE}/{SPEC_FILE}"
README: ClassVar[str] = f"roles/{ROLE}/README.md"
BASE: ClassVar[str] = BASE_PATHS[-1]
EPOCH: ClassVar[int] = 1_700_000_000
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
_isolate_git(self)
self._git("init", "--quiet", "-b", "main")
self._declare(self.ROLE, "1.0.0")
self._write(self.BASE, "BASE = 1\n")
self._commit("add the role", self.EPOCH)
def _git(self, *args: str, **env: str) -> str:
return subprocess.run(
["git", *args],
cwd=self.root,
env={**os.environ, **env},
capture_output=True,
text=True,
check=True,
).stdout.strip()
def _write(self, path: str, content: str) -> None:
target = self.root / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
def _declare(self, role: str, version: str) -> None:
dump_yaml(
self.root / "roles" / role / ROLE_FILE_META_SERVICES,
{
role.removeprefix("web-app-"): {
"version": version,
"lifecycle": "beta",
},
"design": {"enabled": True},
},
)
@staticmethod
def _dated(timestamp: int) -> dict[str, str]:
date = f"{timestamp} +0000"
return {"GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date}
def _commit(self, message: str, timestamp: int) -> str:
self._git("add", "--all")
self._git("commit", "--quiet", "--message", message, **self._dated(timestamp))
return self._git("rev-parse", "HEAD")
def _design(self, timestamp: int) -> str:
self._write(self.SPEC, "// design spec\n")
return self._commit("design pass", timestamp)
def _move_base(self, timestamp: int) -> None:
self._write(self.BASE, "BASE = 2\n")
self._commit("move the design base", timestamp)
def _facts_of(self, role: str) -> RoleFacts:
return next(facts for facts in collect_facts(self.root) if facts.role == role)
def _facts(self) -> RoleFacts:
return self._facts_of(self.ROLE)
def _index(self) -> bytes:
with (self.root / ".git" / "index").open("rb") as handle:
return handle.read()
def test_state_follows_the_role_history(self) -> None:
states = [state_of(self._facts())]
self._design(self.EPOCH + 60)
states.append(state_of(self._facts()))
self._move_base(self.EPOCH + 120)
self._declare(self.ROLE, "2.0.0")
states.append(state_of(self._facts()))
self.assertEqual(states, ["new", "current", "stale"])
def test_untracked_spec_is_in_review(self) -> None:
self._write(self.SPEC, "// design spec\n")
self.assertTrue(self._facts().in_review)
def test_untracked_spec_is_in_review_when_git_hides_untracked_files(self) -> None:
self._git("config", "status.showUntrackedFiles", "no")
self._write(self.SPEC, "// design spec\n")
self.assertTrue(self._facts().in_review)
def test_modified_spec_is_in_review(self) -> None:
self._design(self.EPOCH + 60)
self._write(self.SPEC, "// reworked design spec\n")
self.assertTrue(self._facts().in_review)
def test_committed_spec_is_not_in_review(self) -> None:
self._design(self.EPOCH + 60)
self.assertFalse(self._facts().in_review)
def test_designed_is_the_last_commit_of_the_spec(self) -> None:
sha = self._design(self.EPOCH + 60)
self._write(self.README, "# Demo\n")
self._commit("document the role", self.EPOCH + 120)
self.assertEqual(
self._facts().designed, DesignCommit(sha=sha, timestamp=self.EPOCH + 60)
)
def test_removed_spec_counts_as_never_designed(self) -> None:
self._design(self.EPOCH + 60)
(self.root / self.SPEC).unlink()
self._commit("drop the design spec", self.EPOCH + 120)
self.assertIsNone(self._facts().designed)
def test_base_commit_after_the_design_pass_is_a_base_change(self) -> None:
self._design(self.EPOCH + 60)
self._move_base(self.EPOCH + 120)
self.assertTrue(self._facts().base_changed)
def test_base_commit_before_the_design_pass_is_no_base_change(self) -> None:
self._move_base(self.EPOCH + 60)
self._design(self.EPOCH + 120)
self.assertFalse(self._facts().base_changed)
def test_base_commit_in_the_second_of_the_design_pass_is_a_base_change(
self,
) -> None:
self._design(self.EPOCH + 60)
self._move_base(self.EPOCH + 60)
self.assertTrue(self._facts().base_changed)
def test_version_bump_since_the_design_pass_is_measured(self) -> None:
self._design(self.EPOCH + 60)
self._declare(self.ROLE, "3.1.0")
facts = self._facts()
self.assertEqual(
(facts.designed_version, facts.current_version, facts.version_gap),
("1.0.0", "3.1.0", (2, 1, 0)),
)
def test_commit_times_span_the_role_history(self) -> None:
self._write(self.README, "# Demo\n")
self._commit("document the role", self.EPOCH + 60)
facts = self._facts()
self.assertEqual((facts.created, facts.changed), (self.EPOCH, self.EPOCH + 60))
def test_commit_outside_the_role_keeps_its_change_time(self) -> None:
self._move_base(self.EPOCH + 60)
self.assertEqual(self._facts().changed, self.EPOCH)
def test_role_without_commit_has_no_commit_times(self) -> None:
self._declare("web-app-draft", "1.0.0")
draft = self._facts_of("web-app-draft")
self.assertEqual((draft.created, draft.changed), (None, None))
def test_oldest_commit_is_found_behind_a_merge_of_parallel_histories(self) -> None:
self._git("checkout", "--quiet", "-b", "side")
self._declare("web-app-twin", "1.0.0")
self._commit("add the twin role on the side branch", self.EPOCH + 60)
self._git("checkout", "--quiet", "main")
self._declare("web-app-twin", "1.0.0")
self._commit("add the twin role on main", self.EPOCH + 120)
self._git(
"merge",
"--quiet",
"--no-ff",
"--message",
"merge the side branch",
"side",
**self._dated(self.EPOCH + 180),
)
self.assertEqual(self._facts_of("web-app-twin").created, self.EPOCH + 60)
def test_collecting_facts_leaves_the_index_untouched(self) -> None:
self._design(self.EPOCH + 60)
os.utime(self.root / self.SPEC, (self.EPOCH, self.EPOCH))
index = self._index()
self._facts()
self.assertEqual(self._index(), index)
class TestRepositoryRoot(unittest.TestCase):
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name).resolve()
_isolate_git(self)
def test_subdirectory_resolves_to_the_work_tree_root(self) -> None:
subprocess.run(
["git", "init", "--quiet", "-b", "main"],
cwd=self.root,
capture_output=True,
check=True,
)
nested = self.root / "roles" / "web-app-demo"
nested.mkdir(parents=True)
self.assertEqual(repository_root(nested), self.root)
def test_directory_outside_a_work_tree_raises_git_error(self) -> None:
ceiling = {"GIT_CEILING_DIRECTORIES": str(self.root.parent)}
with patch.dict(os.environ, ceiling), self.assertRaises(GitError):
repository_root(self.root)
def test_missing_git_binary_raises_git_error(self) -> None:
with (
patch(
"cli.meta.roles.design.subprocess.run",
side_effect=FileNotFoundError("git"),
),
self.assertRaises(GitError),
):
repository_root(self.root)
class TestCLIMain(unittest.TestCase):
FACTS: ClassVar[list[RoleFacts]] = [
replace(DESIGNED, role="web-app-stale", current_version="2.0.0"),
replace(NEVER_DESIGNED, role="web-app-review", in_review=True),
DESIGNED,
NEVER_DESIGNED,
]
def _run(self, argv: list[str], facts: list[RoleFacts]) -> tuple[int, str, str]:
out = io.StringIO()
err = io.StringIO()
with (
patch(
"cli.meta.roles.design.__main__.repository_root",
return_value=Path("/repo"),
),
patch("cli.meta.roles.design.__main__.collect_facts", return_value=facts),
redirect_stdout(out),
redirect_stderr(err),
):
code = cli_main(argv)
return code, out.getvalue(), err.getvalue()
def test_next_prints_nothing_when_no_role_is_due(self) -> None:
self.assertEqual(self._run(["--next"], [DESIGNED]), (0, "", ""))
def test_next_prints_the_first_due_role(self) -> None:
self.assertEqual(self._run(["--next"], self.FACTS), (0, "web-app-fresh\n", ""))
def test_table_lists_the_due_queue_and_the_summary(self) -> None:
_, out, _ = self._run([], self.FACTS)
self.assertEqual(
out.splitlines(),
[
"rank role state reason",
"1 web-app-fresh new never designed",
"2 web-app-stale stale version 1.0.0 -> 2.0.0",
"",
"due: 2, in review: 1, current: 1",
],
)
def test_all_appends_the_roles_that_are_not_due(self) -> None:
_, out, _ = self._run(["--all"], self.FACTS)
self.assertEqual(
out.splitlines()[3:5],
[
" web-app-review review design spec awaits approval",
" web-app-designed current up to date",
],
)
def test_json_emits_the_queue_fields(self) -> None:
stale = replace(DESIGNED, role="web-app-stale", current_version="2.1.0")
_, out, _ = self._run(["--format", "json"], [stale])
self.assertEqual(
json.loads(out),
[
{
"rank": 1,
"role": "web-app-stale",
"state": "stale",
"reason": "version 1.0.0 -> 2.1.0",
"version_gap": [1, 1, 0],
"created": 100,
"changed": 100,
}
],
)
def test_git_failure_exits_non_zero_with_a_message(self) -> None:
out = io.StringIO()
err = io.StringIO()
with (
patch(
"cli.meta.roles.design.__main__.repository_root",
side_effect=GitError("cannot run git"),
),
redirect_stdout(out),
redirect_stderr(err),
):
code = cli_main([])
self.assertEqual((code, out.getvalue()), (1, ""))
self.assertIn("cannot run git", err.getvalue())
if __name__ == "__main__":
unittest.main()