Files changed: - .gitignore - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/config.py - tools/chemenu/errors.py - tools/chemenu/tasks/__init__.py - tools/chemenu/tasks/config.py - tools/chemenu/tasks/protocol.py - tools/chemenu/tasks/superproductivity.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_superproductivity.py - tools/chemenu/tests/test_tasks_config.py - tools/chemenu/tests/test_tasks_protocol.py
299 lines
9.8 KiB
Python
299 lines
9.8 KiB
Python
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124).
|
|
|
|
The fixture snapshot below mirrors the real shape verified against
|
|
`super-productivity/super-productivity`'s `master` branch on 2026-09-19: a
|
|
flat top-level object with `project`/`task`/`tag` as `@ngrx/entity`
|
|
`{"ids": [...], "entities": {...}}` maps (see the module docstring for the
|
|
exact source files). No test here starts a real Super Productivity instance,
|
|
touches the network beyond a loopback socket, or reads a path under the
|
|
developer's home - everything is built inside `tmp_path`
|
|
(`instructions/dev/testing-conventions.md`).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import http.server
|
|
import json
|
|
import threading
|
|
from datetime import date, datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from chemenu.errors import HumanInterventionRequired, ValidationError
|
|
from chemenu.tasks import superproductivity as sp
|
|
|
|
|
|
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 _snapshot() -> dict:
|
|
projects = {
|
|
"p1": {
|
|
"id": "p1",
|
|
"title": "Ship Chemenu 7.0",
|
|
"created": _ms(2026, 1, 1),
|
|
"taskIds": ["t1", "t2"],
|
|
"backlogTaskIds": ["t3"],
|
|
},
|
|
"p2": {
|
|
"id": "p2",
|
|
"title": "Kueche renovieren",
|
|
"created": _ms(2026, 2, 1),
|
|
"taskIds": ["t4"],
|
|
"backlogTaskIds": [],
|
|
},
|
|
}
|
|
tasks = {
|
|
"t1": {
|
|
"id": "t1",
|
|
"title": "Warte auf Angebot vom Elektriker - Tobias",
|
|
"projectId": "p1",
|
|
"isDone": False,
|
|
"tagIds": ["tag-wait"],
|
|
"remindAt": _ms(2026, 3, 1),
|
|
},
|
|
"t2": {
|
|
"id": "t2",
|
|
"title": "Kickoff-Meeting vorbereiten",
|
|
"projectId": "p1",
|
|
"isDone": False,
|
|
"tagIds": [],
|
|
},
|
|
"t3": {
|
|
"id": "t3",
|
|
"title": "Irgendwann Keller aufraeumen",
|
|
"isDone": False,
|
|
"tagIds": [],
|
|
"updated": _ms(2026, 1, 15),
|
|
},
|
|
"t4": {
|
|
"id": "t4",
|
|
"title": "Angebot einholen",
|
|
"projectId": "p2",
|
|
"isDone": True,
|
|
"tagIds": [],
|
|
},
|
|
}
|
|
tags = {
|
|
"tag-wait": {"id": "tag-wait", "title": "Waiting"},
|
|
"tag-urgent": {"id": "tag-urgent", "title": "Urgent"},
|
|
}
|
|
return {
|
|
"project": _entity_state(projects),
|
|
"task": _entity_state(tasks),
|
|
"tag": _entity_state(tags),
|
|
}
|
|
|
|
|
|
def _write_snapshot(path: Path, data: dict | None = None) -> None:
|
|
path.write_text(json.dumps(data if data is not None else _snapshot()), encoding="utf-8")
|
|
|
|
|
|
@pytest.fixture
|
|
def cfg(tmp_path) -> sp.SuperProductivityConfig:
|
|
db_path = tmp_path / "db.json"
|
|
_write_snapshot(db_path)
|
|
return sp.SuperProductivityConfig(
|
|
backups_dir=None, db_path=db_path, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None
|
|
)
|
|
|
|
|
|
# --- from_dict ---------------------------------------------------------------
|
|
|
|
def test_from_dict_requires_backups_dir_or_db_path():
|
|
with pytest.raises(ValidationError):
|
|
sp.SuperProductivityConfig.from_dict({"api_base_url": "http://127.0.0.1:3876"})
|
|
|
|
|
|
def test_from_dict_defaults_the_api_base_url():
|
|
cfg_ = sp.SuperProductivityConfig.from_dict({"db_path": "/x/db.json"})
|
|
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
|
|
|
|
|
|
def test_from_dict_expands_user_in_paths(monkeypatch):
|
|
monkeypatch.setenv("HOME", "/home/fixture")
|
|
cfg_ = sp.SuperProductivityConfig.from_dict({"backups_dir": "~/backups"})
|
|
assert cfg_.backups_dir == Path("/home/fixture/backups")
|
|
|
|
|
|
# --- latest_snapshot_path / schema drift -------------------------------------
|
|
|
|
def test_db_path_must_exist(tmp_path):
|
|
cfg_ = sp.SuperProductivityConfig(
|
|
backups_dir=None, db_path=tmp_path / "missing.json",
|
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
sp.latest_snapshot_path(cfg_)
|
|
|
|
|
|
def test_backups_dir_must_exist(tmp_path):
|
|
cfg_ = sp.SuperProductivityConfig(
|
|
backups_dir=tmp_path / "nope", db_path=None,
|
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
sp.latest_snapshot_path(cfg_)
|
|
|
|
|
|
def test_backups_dir_with_no_json_files_is_an_error(tmp_path):
|
|
backups = tmp_path / "backups"
|
|
backups.mkdir()
|
|
cfg_ = sp.SuperProductivityConfig(
|
|
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
sp.latest_snapshot_path(cfg_)
|
|
|
|
|
|
def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
|
|
backups = tmp_path / "backups"
|
|
backups.mkdir()
|
|
older = _snapshot()
|
|
older["project"]["entities"]["p1"]["title"] = "Old Snapshot Project"
|
|
newer = _snapshot()
|
|
_write_snapshot(backups / "2026-01-01T000000Z.json", older)
|
|
_write_snapshot(backups / "2026-02-01T000000Z.json", newer)
|
|
cfg_ = sp.SuperProductivityConfig(
|
|
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
|
)
|
|
reader = sp.SuperProductivityReader(cfg_)
|
|
names = {p.name for p in reader.projects()}
|
|
assert "Ship Chemenu 7.0" in names
|
|
assert "Old Snapshot Project" not in names
|
|
|
|
|
|
def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
|
|
data = _snapshot()
|
|
data["task"] = ["not", "an", "entity", "state"]
|
|
_write_snapshot(cfg.db_path, data)
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
with pytest.raises(ValidationError, match="task"):
|
|
reader.projects()
|
|
|
|
|
|
def test_missing_top_level_key_fails_loud(cfg):
|
|
data = _snapshot()
|
|
del data["project"]
|
|
_write_snapshot(cfg.db_path, data)
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
with pytest.raises(ValidationError, match="project"):
|
|
reader.projects()
|
|
|
|
|
|
# --- read path ----------------------------------------------------------------
|
|
|
|
def test_projects_lists_name_and_created(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
by_name = {p.name: p for p in reader.projects()}
|
|
assert by_name["Ship Chemenu 7.0"].created == date(2026, 1, 1)
|
|
assert by_name["Kueche renovieren"].created == date(2026, 2, 1)
|
|
|
|
|
|
def test_open_items_counts_undone_tasks_and_matches_name_case_insensitively(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
result = reader.open_items("ship CHEMENU 7.0")
|
|
assert result.count == 2
|
|
|
|
|
|
def test_open_items_reports_waiting_with_follow_up_at_not_due_date(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
result = reader.open_items("Ship Chemenu 7.0")
|
|
assert len(result.waiting) == 1
|
|
waiting = result.waiting[0]
|
|
assert waiting.title == "Warte auf Angebot vom Elektriker - Tobias"
|
|
assert waiting.follow_up_at == date(2026, 3, 1)
|
|
|
|
|
|
def test_open_items_excludes_done_tasks(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
assert reader.open_items("Kueche renovieren").count == 0
|
|
|
|
|
|
def test_open_items_unknown_project_is_empty_not_an_error(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
result = reader.open_items("No Such Project")
|
|
assert result.count == 0
|
|
assert result.waiting == ()
|
|
|
|
|
|
def test_someday_items_come_from_backlog_task_ids_only(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
items = reader.someday_items()
|
|
assert len(items) == 1
|
|
assert items[0].title == "Irgendwann Keller aufraeumen"
|
|
assert items[0].modified == date(2026, 1, 15)
|
|
|
|
|
|
# --- write path: HumanInterventionRequired -----------------------------------
|
|
|
|
def test_create_project_refuses_a_name_collision(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
writer = sp.SuperProductivityWriter(cfg, reader)
|
|
with pytest.raises(ValidationError):
|
|
writer.create_project("ship chemenu 7.0")
|
|
|
|
|
|
def test_create_project_asks_a_human_and_verify_reflects_the_read_path(cfg):
|
|
reader = sp.SuperProductivityReader(cfg)
|
|
writer = sp.SuperProductivityWriter(cfg, reader)
|
|
|
|
with pytest.raises(HumanInterventionRequired) as excinfo:
|
|
writer.create_project("Kueche renovieren, Phase 2")
|
|
|
|
exc = excinfo.value
|
|
assert "POST" not in str(exc) # instructions are for a human, not an HTTP client
|
|
assert "Kueche renovieren, Phase 2" in str(exc)
|
|
assert exc.verify() is False
|
|
|
|
data = _snapshot()
|
|
data["project"]["ids"].append("p3")
|
|
data["project"]["entities"]["p3"] = {
|
|
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
|
|
"taskIds": [], "backlogTaskIds": [],
|
|
}
|
|
_write_snapshot(cfg.db_path, data)
|
|
|
|
assert exc.verify() is True
|
|
|
|
|
|
# --- health -------------------------------------------------------------------
|
|
|
|
def test_health_is_false_when_nothing_listens(tmp_path):
|
|
cfg_ = sp.SuperProductivityConfig(
|
|
backups_dir=None, db_path=tmp_path / "db.json",
|
|
api_base_url="http://127.0.0.1:1", api_token=None,
|
|
)
|
|
assert sp.health(cfg_, timeout=0.5) is False
|
|
|
|
|
|
class _HealthHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self): # noqa: N802 - stdlib method name
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"ok": true}')
|
|
|
|
def log_message(self, *args): # silence stderr noise during the test run
|
|
pass
|
|
|
|
|
|
def test_health_is_true_when_the_endpoint_answers(tmp_path):
|
|
server = http.server.HTTPServer(("127.0.0.1", 0), _HealthHandler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
port = server.server_address[1]
|
|
cfg_ = sp.SuperProductivityConfig(
|
|
backups_dir=None, db_path=tmp_path / "db.json",
|
|
api_base_url=f"http://127.0.0.1:{port}", api_token=None,
|
|
)
|
|
assert sp.health(cfg_, timeout=2.0) is True
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=2)
|