|
| 1 | +"""Slice for the gate-promotion lifecycle (issue #156) — `gates.promotion_candidates`. |
| 2 | +
|
| 3 | +An advisory check carrying `promote_after = N` earns promotion to gating once it has PASSED |
| 4 | +in its N most-recent frozen cycles (a hint; the human flips `gating`). Frozen |
| 5 | +`check-gates.json` records drive it; `state` is mocked COMPLETE so the test needn't assemble |
| 6 | +full SUMMARY sign-offs. Run from the project root: |
| 7 | + PYTHONPATH=src python -m unittest discover -s tests |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import json |
| 13 | +import shutil |
| 14 | +import tempfile |
| 15 | +import unittest |
| 16 | +from pathlib import Path |
| 17 | +from unittest import mock |
| 18 | + |
| 19 | +from pdca_harness import gates, state |
| 20 | +from pdca_harness.config import Config, LeafConfig |
| 21 | + |
| 22 | +_CHECK = {"id": "C5-prod", "label": "test exercises production", "scope": "bundle", |
| 23 | + "gating": False, "promote_after": 3} |
| 24 | + |
| 25 | + |
| 26 | +def _cfg(root: Path, checks: list[dict]) -> Config: |
| 27 | + return Config( |
| 28 | + root=root, bundle_root=root / "results", process_dir=root / "process", |
| 29 | + templates_dir=root / "templates", default_branch="main", tracker_system="github", |
| 30 | + tracker_url="", issue_id_example="#1", |
| 31 | + builder=LeafConfig(mode="stub"), reviewer=LeafConfig(mode="stub"), |
| 32 | + gates_checks=checks) |
| 33 | + |
| 34 | + |
| 35 | +class Promotion(unittest.TestCase): |
| 36 | + def setUp(self) -> None: |
| 37 | + self.tmp = Path(tempfile.mkdtemp()) |
| 38 | + |
| 39 | + def tearDown(self) -> None: |
| 40 | + shutil.rmtree(self.tmp, ignore_errors=True) |
| 41 | + |
| 42 | + def _bundle(self, cfg: Config, name: str, date: str, result: str) -> None: |
| 43 | + d = cfg.bundle(name) |
| 44 | + d.mkdir(parents=True) |
| 45 | + (d / "check-gates.json").write_text( |
| 46 | + json.dumps({"rows": [{"rule_id": "C5-prod", "result": result}]}), |
| 47 | + encoding="utf-8") |
| 48 | + (d / "SUMMARY.md").write_text( |
| 49 | + f"## 9. Check sign-off\n- By / date: t / {date}\n", encoding="utf-8") |
| 50 | + |
| 51 | + def _candidates(self, cfg: Config) -> list[dict]: |
| 52 | + with mock.patch.object(gates.state, "state", return_value=state.COMPLETE): |
| 53 | + return gates.promotion_candidates(cfg) |
| 54 | + |
| 55 | + def _three(self, cfg: Config, results: tuple[str, str, str]) -> None: |
| 56 | + for (name, date), res in zip( |
| 57 | + [("A", "2026-06-01"), ("B", "2026-06-02"), ("C", "2026-06-03")], results): |
| 58 | + self._bundle(cfg, name, date, res) |
| 59 | + |
| 60 | + def test_ready_when_clean_for_threshold(self) -> None: |
| 61 | + cfg = _cfg(self.tmp, [_CHECK]) |
| 62 | + self._three(cfg, ("pass", "pass", "pass")) |
| 63 | + self.assertEqual([c["id"] for c in self._candidates(cfg)], ["C5-prod"]) |
| 64 | + |
| 65 | + def test_not_ready_when_most_recent_failed(self) -> None: |
| 66 | + cfg = _cfg(self.tmp, [_CHECK]) |
| 67 | + self._three(cfg, ("pass", "pass", "fail")) # newest (C, 06-03) failed |
| 68 | + self.assertEqual(self._candidates(cfg), []) |
| 69 | + |
| 70 | + def test_unverifiable_breaks_the_streak(self) -> None: |
| 71 | + cfg = _cfg(self.tmp, [_CHECK]) |
| 72 | + self._three(cfg, ("pass", "pass", "unverifiable")) |
| 73 | + self.assertEqual(self._candidates(cfg), []) |
| 74 | + |
| 75 | + def test_not_ready_below_threshold(self) -> None: |
| 76 | + cfg = _cfg(self.tmp, [_CHECK]) |
| 77 | + self._bundle(cfg, "A", "2026-06-01", "pass") |
| 78 | + self._bundle(cfg, "B", "2026-06-02", "pass") # only 2 runs, threshold is 3 |
| 79 | + self.assertEqual(self._candidates(cfg), []) |
| 80 | + |
| 81 | + def test_gating_check_not_a_candidate(self) -> None: |
| 82 | + cfg = _cfg(self.tmp, [{**_CHECK, "gating": True}]) |
| 83 | + self._three(cfg, ("pass", "pass", "pass")) |
| 84 | + self.assertEqual(self._candidates(cfg), []) |
| 85 | + |
| 86 | + def test_without_promote_after_not_a_candidate(self) -> None: |
| 87 | + cfg = _cfg(self.tmp, [{k: v for k, v in _CHECK.items() if k != "promote_after"}]) |
| 88 | + self._three(cfg, ("pass", "pass", "pass")) |
| 89 | + self.assertEqual(self._candidates(cfg), []) |
| 90 | + |
| 91 | + |
| 92 | +if __name__ == "__main__": |
| 93 | + unittest.main() |
0 commit comments