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
594 lines
24 KiB
Python
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 == ""
|