Files
chemenu/tools/chemenu/tests/test_review.py
T
torben 6d53c55d0d
CI / verify (push) Successful in 1m12s
Release / release (push) Successful in 39s
tasks: CalDAV provider (Nextcloud Tasks/iOS), review reports unknown values; bump stops pointing at release (#139)
Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/dev/version-parts.md
- instructions/gtd-weekly-review/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/review.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/caldav.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tests/test_caldav.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_review.py
- tools/requirements.txt
2026-09-25 20:43:53 +02:00

594 lines
24 KiB
Python

"""Tests for `chemenu.review` and `wikitool review` (Gitea #125).
`run_review` takes its root as an explicit argument and never touches
`config.ROOT`, so these tests build a tree under `tmp_path` without needing
`kb_dir`/`use_shipped_type_specs` - the real shipped `types/project.md` is
what `config.ROOT` already falls back to under the hermetic fixture (see
`instructions/dev/testing-conventions.md`), and that is the schema this
feature is meant to be exercised against, not a synthetic stand-in.
"""
from __future__ import annotations
import json
import subprocess
from datetime import date, datetime, timezone
from pathlib import Path
import pytest
from typer.testing import CliRunner
from chemenu.cli import app
from chemenu.commands.run_budget import is_exempt
from chemenu.errors import ValidationError
from chemenu.frontmatter_io import write_page
from chemenu.tests.conftest import use_shipped_type_specs
from chemenu.review import (
CHECK_NO_OPEN_LOOP,
CHECK_PROJECT_AGE_UNKNOWN,
CHECK_SOMEDAY_STALE,
CHECK_STALLED,
CHECK_UNPAGED_PROJECT,
CHECK_WAITING_NO_FOLLOWUP,
CHECK_WAITING_OVERDUE,
run_review,
)
from chemenu.tests.test_caldav import (
TOKEN as _CALDAV_TOKEN,
_calendars_multistatus,
_canned_server,
_vtodo_ics,
_vtodos_multistatus,
)
runner = CliRunner()
TODAY = date(2026, 9, 19)
THRESHOLDS = {
"stalled_waiting_days": 14,
"unpaged_project_weeks": 3,
"someday_stale_months": 5,
}
def _ms(year: int, month: int, day: int) -> int:
return int(datetime(year, month, day, tzinfo=timezone.utc).timestamp() * 1000)
def _entity_state(records: dict[str, dict]) -> dict:
return {"ids": list(records.keys()), "entities": records}
def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path:
"""Writes a Super Productivity backup snapshot into a fresh `backups/`
directory under `root`, named the way `electron/backup.ts` actually
names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133) so the hardened glob in
`latest_snapshot_path` picks it up. Returns the `backups_dir`, not the
file itself - that is what a `superproductivity` config section names."""
backups_dir = root / "backups"
backups_dir.mkdir(exist_ok=True)
(backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({
"project": _entity_state(projects),
"task": _entity_state(tasks),
"tag": _entity_state(tags),
}),
encoding="utf-8",
)
return backups_dir
def _write_tasks_config(root: Path, backups_dir: Path, thresholds: dict | None = None) -> None:
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1,
"provider": "superproductivity",
"thresholds": thresholds or THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
def _write_caldav_tasks_config(root: Path, *, url: str, thresholds: dict | None = None) -> None:
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1,
"provider": "caldav",
"thresholds": thresholds or THRESHOLDS,
"caldav": {
"url": url, "username": _CALDAV_TOKEN[0], "app_password": _CALDAV_TOKEN[1],
"inbox_list": "Inbox", "someday_list": "Someday",
},
}),
encoding="utf-8",
)
def _project_page(root: Path, name: str, state: str, *, area: str = "haus") -> None:
write_page(
root / "kb" / "gtd" / area / f"{name}.md",
{
"type": "types/project.md",
"state": state,
"responsibility": area,
"created": "2026-01-01",
"modified": "2026-01-01",
"provenance": "general",
"summary": f"Fixture project {name}.",
},
f"\n# {name}\n\n## Ziel\n\nFixture.\n",
)
def _tree(root: Path) -> dict[str, bytes]:
return {
str(p.relative_to(root)): p.read_bytes()
for p in root.rglob("*") if p.is_file()
}
# --- check 1: stalled --------------------------------------------------------
@pytest.mark.parametrize("state,should_fire", [
("active", True),
("dormant", False),
("completed", False),
("abandoned", False),
])
def test_check1_stalled_only_fires_for_active(tmp_path, state, should_fire):
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", state)
report = run_review(tmp_path, today=TODAY)
fired = any(f.check == CHECK_STALLED for f in report.findings)
assert fired == should_fire
# --- check 2: waiting overdue ------------------------------------------------
@pytest.mark.parametrize("remind_day,should_fire", [
(1, True), # 2026-01-01 -> far more than 14 days before TODAY
(10, False), # 2026-09-10 -> 9 days before TODAY, under the threshold
])
def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
month = 1 if remind_day == 1 else 9
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueWithTime": _ms(2026, month, remind_day)}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
fired = any(f.check == CHECK_WAITING_OVERDUE for f in report.findings)
assert fired == should_fire
def test_check2_finding_carries_the_waiting_items_own_id(tmp_path):
"""Gitea #138 - `gtd-weekly-review`'s `task close --id` proposal reads
this off the finding rather than re-looking the item up by title."""
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueWithTime": _ms(2026, 1, 1)}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
finding = next(f for f in report.findings if f.check == CHECK_WAITING_OVERDUE)
assert finding.item_id == "t1"
def test_check2_fires_for_an_all_day_waiting_item_with_no_due_with_time(tmp_path):
"""The gap Gitea #135 closed: a `waiting` task scheduled all-day
(`dueDay`, no `dueWithTime`, no reminder) must still surface as overdue -
under #124's original `remindAt` mapping it silently never did."""
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueDay": "2026-01-01"}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
assert any(f.check == CHECK_WAITING_OVERDUE for f in report.findings)
# --- check 3: unpaged tracker project ----------------------------------------
@pytest.mark.parametrize("created_year_month_day,should_fire", [
((2026, 9, 15), False), # 4 days old, well under 3 weeks
((2026, 8, 1), True), # ~7 weeks old
])
def test_check3_unpaged_project_age_threshold(tmp_path, created_year_month_day, should_fire):
y, m, d = created_year_month_day
projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
(tmp_path / "kb" / "gtd").mkdir(parents=True)
report = run_review(tmp_path, today=TODAY)
fired = any(f.check == CHECK_UNPAGED_PROJECT for f in report.findings)
assert fired == should_fire
def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
"""'Kueche renovieren' and 'kueche renovieren' are the same project (#119
D8) - no unpaged/no-open-loop finding from the case difference alone."""
projects = {"p1": {"id": "p1", "title": "kueche renovieren", "created": _ms(2020, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Irgendwas tun", "isDone": False, "tagIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
assert not any(f.check == CHECK_UNPAGED_PROJECT for f in report.findings)
assert not any(f.check == CHECK_NO_OPEN_LOOP for f in report.findings)
# --- D3 (Gitea #139): a value the provider cannot supply is its own finding --------
def test_d3_sp_waiting_with_no_follow_up_at_is_its_own_finding_not_a_silent_skip(tmp_path):
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"]}} # no dueWithTime, no dueDay
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
findings = [f for f in report.findings if f.project == "Kueche renovieren"]
assert any(f.check == CHECK_WAITING_NO_FOLLOWUP for f in findings)
assert not any(f.check == CHECK_WAITING_OVERDUE for f in findings)
assert report.complete
def test_d3_sp_project_with_no_created_date_is_its_own_finding_not_a_silent_skip(tmp_path):
projects = {"p1": {"id": "p1", "title": "No Page Yet", "taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
(tmp_path / "kb" / "gtd").mkdir(parents=True)
report = run_review(tmp_path, today=TODAY)
findings = [f for f in report.findings if f.project == "No Page Yet"]
assert any(f.check == CHECK_PROJECT_AGE_UNKNOWN for f in findings)
assert not any(f.check == CHECK_UNPAGED_PROJECT for f in findings)
assert report.complete
def test_d3_caldav_waiting_with_no_follow_up_at_is_its_own_finding(tmp_path):
entries = [
("/cal/proj1/", "Kueche renovieren", frozenset({"VTODO"})),
("/cal/someday/", "Someday", frozenset({"VTODO"})),
]
ics = _vtodo_ics(uid="t1", summary="Warte auf Angebot", categories=["waiting"]) # no dtstart
with _canned_server({
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])),
("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([])),
}) as server:
_write_caldav_tasks_config(tmp_path, url=f"http://127.0.0.1:{server.server_address[1]}/cal/")
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
findings = [f for f in report.findings if f.project == "Kueche renovieren"]
assert any(f.check == CHECK_WAITING_NO_FOLLOWUP for f in findings)
assert not any(f.check == CHECK_WAITING_OVERDUE for f in findings)
assert report.complete
def test_d3_caldav_project_with_no_determinable_created_date_is_its_own_finding(tmp_path):
"""No CalDAV server in #139's own test account ever returns
`DAV:creationdate` on a calendar - an empty project is the concrete case
where the substitute value (earliest item `CREATED`) has nothing to work
from."""
entries = [
("/cal/proj1/", "No Page Yet", frozenset({"VTODO"})),
("/cal/someday/", "Someday", frozenset({"VTODO"})),
]
with _canned_server({
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([])),
("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([])),
}) as server:
_write_caldav_tasks_config(tmp_path, url=f"http://127.0.0.1:{server.server_address[1]}/cal/")
(tmp_path / "kb" / "gtd").mkdir(parents=True)
report = run_review(tmp_path, today=TODAY)
findings = [f for f in report.findings if f.project == "No Page Yet"]
assert any(f.check == CHECK_PROJECT_AGE_UNKNOWN for f in findings)
assert not any(f.check == CHECK_UNPAGED_PROJECT for f in findings)
assert report.complete
def test_d3_text_and_json_output_agree(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False, "tagIds": ["tag-wait"]}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
text_result = runner.invoke(app, ["review"])
json_result = runner.invoke(app, ["review", "--json"])
assert text_result.exit_code == 0
assert json_result.exit_code == 0
payload = json.loads(json_result.output)
json_pairs = {(f["check"], f["project"]) for f in payload["findings"]}
import re
text_pairs = {
(m.group(1), m.group(2))
for m in re.finditer(r"^\[(\S+)\] ([^:]+):", text_result.output, re.MULTILINE)
}
assert json_pairs == text_pairs
assert (CHECK_WAITING_NO_FOLLOWUP, "Kueche renovieren") in json_pairs
# --- check 4: kb/ page with no open loop -------------------------------------
def test_check4_fires_when_no_tracker_project_exists(tmp_path):
backups_dir = _write_snapshot(tmp_path, {}, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ghost Project", "active")
report = run_review(tmp_path, today=TODAY)
findings = [f for f in report.findings if f.check == CHECK_NO_OPEN_LOOP]
assert len(findings) == 1
assert findings[0].project == "Ghost Project"
# --- run isolation and file mutation -----------------------------------------
def test_a_run_touches_no_file(tmp_path):
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [],
"updated": _ms(2020, 1, 1)}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
before = _tree(tmp_path)
run_review(tmp_path, today=TODAY)
assert _tree(tmp_path) == before
# --- check 5: someday stale ---------------------------------------------------
@pytest.mark.parametrize("updated_ymd,should_fire", [
((2026, 8, 1), False), # ~1.5 months old
((2025, 1, 1), True), # well over 5 months old
])
def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire):
y, m, _d = updated_ymd
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
"tagIds": [], "updated": _ms(y, m, 1)}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY)
fired = any(f.check == CHECK_SOMEDAY_STALE for f in report.findings)
assert fired == should_fire
def test_check5_finding_carries_the_someday_items_own_id(tmp_path):
"""Gitea #138 - the same id the `task close --id` proposal needs."""
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
"tagIds": [], "updated": _ms(2025, 1, 1)}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY)
finding = next(f for f in report.findings if f.check == CHECK_SOMEDAY_STALE)
assert finding.item_id == "t3"
def test_findings_with_no_specific_item_carry_no_item_id(tmp_path):
"""Checks 1, 3 and 4 are about a whole project, not one item (Gitea
#138) - their findings must not invent an id there is none for."""
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
report = run_review(tmp_path, today=TODAY)
assert report.findings
assert all(f.item_id is None for f in report.findings)
# --- provider/configuration errors -------------------------------------------
def test_no_tasks_config_is_a_clear_validation_error(tmp_path):
(tmp_path / "kb" / "gtd").mkdir(parents=True)
with pytest.raises(ValidationError, match="no task tracker is configured"):
run_review(tmp_path, today=TODAY)
def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path):
"""A backups_dir that does not exist is the unreachable-provider case: checks
1/2/3/4 cannot run at all, but check 5 uses the same failing read and is
skipped too - the report must say so, never look like a quiet week."""
_write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
report = run_review(tmp_path, today=TODAY)
assert not report.complete
skipped_checks = {check for check, _reason in report.checks_skipped}
assert skipped_checks == {
CHECK_STALLED, CHECK_WAITING_OVERDUE, CHECK_UNPAGED_PROJECT,
CHECK_NO_OPEN_LOOP, CHECK_SOMEDAY_STALE,
}
assert report.kb_project_count == 1
assert report.findings == ()
# --- CLI: exit codes, --json, budget exemption -------------------------------
def test_cli_no_provider_exits_1_with_a_clear_message(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
(tmp_path / "kb" / "gtd").mkdir(parents=True)
result = runner.invoke(app, ["review"])
assert result.exit_code == 1
assert "no task tracker is configured" in result.output
def test_cli_incomplete_report_exits_1_and_still_prints(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
_write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
(tmp_path / "kb" / "gtd").mkdir(parents=True)
result = runner.invoke(app, ["review"])
assert result.exit_code == 1
assert "INCOMPLETE" in result.output
def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
"""The golden check (#125's own AC): the `--json` findings and the
text-rendered findings must name exactly the same (check, project) pairs -
the same posture `test_mcp_server.py` holds the MCP wire format to
against the CLI's own `--json`."""
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
text_result = runner.invoke(app, ["review"])
json_result = runner.invoke(app, ["review", "--json"])
assert text_result.exit_code == 0
assert json_result.exit_code == 0
payload = json.loads(json_result.output)
json_pairs = {(f["check"], f["project"]) for f in payload["findings"]}
import re
text_pairs = {
(m.group(1), m.group(2))
for m in re.finditer(r"^\[(\S+)\] ([^:]+):", text_result.output, re.MULTILINE)
}
# A tracker project with zero open items and an active kb/ page satisfies
# both check 1's and check 4's condition (#125's table: check 4's "no
# open items" branch is not exclusive of check 1) - both fire.
assert json_pairs == text_pairs == {
(CHECK_STALLED, "Ship Chemenu 7.0"),
(CHECK_NO_OPEN_LOOP, "Ship Chemenu 7.0"),
}
def test_report_names_its_source_and_the_snapshot_age(tmp_path):
"""Gitea #133's own AC: every answer says which access path it came from,
and a snapshot answer says how old it is."""
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY)
assert report.source is not None
assert report.source.kind == "snapshot"
assert "day(s) old" in report.source.detail
def test_cli_shows_the_source_in_both_render_forms(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
text_result = runner.invoke(app, ["review"])
json_result = runner.invoke(app, ["review", "--json"])
assert "Source: snapshot" in text_result.output
payload = json.loads(json_result.output)
assert payload["source"]["kind"] == "snapshot"
assert "day(s) old" in payload["source"]["detail"]
def test_review_is_exempt_from_the_iteration_budget():
assert is_exempt("review", []) is True
assert is_exempt("review", ["--json"]) is True
@pytest.mark.skipif(
subprocess.run(["git", "--version"], capture_output=True).returncode != 0,
reason="git not available",
)
def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
from chemenu import config
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "Fixture Author"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "fixture@example.com"], cwd=tmp_path, check=True)
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=tmp_path, check=True)
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
before = subprocess.run(
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True
).stdout
runner.invoke(app, ["review", "--json"])
after = subprocess.run(
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True
).stdout
assert before == after == ""