SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
Files changed: - CHANGES.md - INSTALL.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/doctor.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/review_cmd.py - tools/chemenu/review.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_new_page.py - tools/chemenu/tests/test_review.py - tools/chemenu/tests/test_superproductivity.py - tools/chemenu/tests/test_tasks_config.py
This commit is contained in:
1 parent
3c1d4cb028
commit
e07d1ca42a
17 files changed
+1112
-239
No files matched your search
@@ -426,6 +426,11 @@ def check_tasks_provider() -> Check:
|
||||
"nothing configured". Provider reachability itself never affects the
|
||||
exit code, same as `check_git_repo`'s remote check: the app being closed
|
||||
is normal, not a fault.
|
||||
|
||||
For `superproductivity`, only the instance's configured `access` path is
|
||||
ever attempted (Gitea #133) - `api` reports API reachability, `snapshot`
|
||||
reports whether a backup file is ready; the other path is simply not a
|
||||
finding, since this instance never touches it.
|
||||
"""
|
||||
from chemenu import config
|
||||
from chemenu.errors import ValidationError
|
||||
@@ -456,14 +461,20 @@ def check_tasks_provider() -> Check:
|
||||
"tasks-provider", "FAIL", str(exc),
|
||||
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}",
|
||||
)
|
||||
# Only the configured access path is a finding (Gitea #133) - the
|
||||
# other one is not attempted at all, so it has nothing to report.
|
||||
if sp_cfg.access == sp.ACCESS_API:
|
||||
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
|
||||
return Check(
|
||||
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}",
|
||||
)
|
||||
try:
|
||||
snapshot_path = sp.latest_snapshot_path(sp_cfg)
|
||||
read_state = f"read path OK, newest snapshot {rel_path(snapshot_path)}"
|
||||
except ValidationError as exc:
|
||||
read_state = f"read path not ready ({exc})"
|
||||
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
|
||||
return Check(
|
||||
"tasks-provider", "OK", f"superproductivity: {read_state}; {api_state}",
|
||||
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}",
|
||||
)
|
||||
|
||||
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
|
||||
|
||||
@@ -290,6 +290,15 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
|
||||
return None
|
||||
|
||||
reader = tasks.build_reader(cfg)
|
||||
try:
|
||||
writer = tasks.build_writer(cfg, reader)
|
||||
except ValidationError as exc:
|
||||
# E.g. a superproductivity instance with access: "snapshot" (Gitea
|
||||
# #133) - the tracker is read-only from here, so this refuses before
|
||||
# either the collision check or the page write, exactly like any
|
||||
# other precondition failure.
|
||||
fail(str(exc))
|
||||
|
||||
existing = find_project(reader, page_title)
|
||||
if existing is not None:
|
||||
if resume:
|
||||
@@ -302,7 +311,6 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
|
||||
"instead of being refused here."
|
||||
)
|
||||
|
||||
writer = tasks.build_writer(cfg, reader)
|
||||
try:
|
||||
writer.create_project(page_title)
|
||||
except HumanInterventionRequired as exc:
|
||||
|
||||
@@ -23,6 +23,8 @@ def render_report(report: ReviewReport) -> str:
|
||||
message`, so a hit can be told apart from the summary line without a
|
||||
schema - the same shape `search`'s table takes for the same reason."""
|
||||
lines: list[str] = []
|
||||
if report.source is not None:
|
||||
lines.append(f"Source: {report.source.kind} ({report.source.detail})")
|
||||
if report.checks_skipped:
|
||||
lines.append("INCOMPLETE - the following check(s) did not run:")
|
||||
for check, reason in report.checks_skipped:
|
||||
@@ -59,6 +61,11 @@ def report_to_dict(report: ReviewReport) -> dict:
|
||||
],
|
||||
"kb_project_count": report.kb_project_count,
|
||||
"complete": report.complete,
|
||||
"source": (
|
||||
{"kind": report.source.kind, "detail": report.source.detail}
|
||||
if report.source is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+11
-1
@@ -27,7 +27,7 @@ from chemenu import config, kb_scan
|
||||
from chemenu.errors import ValidationError
|
||||
from chemenu.tasks import build_reader
|
||||
from chemenu.tasks import config as tasks_config
|
||||
from chemenu.tasks.protocol import OpenItems, normalize_project_name
|
||||
from chemenu.tasks.protocol import OpenItems, ReadSource, normalize_project_name
|
||||
|
||||
CHECK_STALLED = "stalled"
|
||||
CHECK_WAITING_OVERDUE = "waiting_overdue"
|
||||
@@ -61,6 +61,7 @@ class ReviewReport:
|
||||
checks_run: tuple[str, ...]
|
||||
checks_skipped: tuple[tuple[str, str], ...] # (check, reason)
|
||||
kb_project_count: int
|
||||
source: Optional[ReadSource]
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
@@ -128,6 +129,14 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
reader = build_reader(cfg)
|
||||
kb_projects = _load_kb_projects(Path(root) / "kb")
|
||||
|
||||
try:
|
||||
source: Optional[ReadSource] = reader.source()
|
||||
except ValidationError:
|
||||
# The same read that would answer this has already failed, or is
|
||||
# about to below - `source` degrading to `None` here is no worse
|
||||
# than the check it would have described also being skipped.
|
||||
source = None
|
||||
|
||||
findings: list[Finding] = []
|
||||
checks_run: list[str] = []
|
||||
checks_skipped: list[tuple[str, str]] = []
|
||||
@@ -248,4 +257,5 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
checks_run=tuple(checks_run),
|
||||
checks_skipped=tuple(checks_skipped),
|
||||
kb_project_count=len(kb_projects),
|
||||
source=source,
|
||||
)
|
||||
@@ -24,11 +24,16 @@ from chemenu.tasks.protocol import TaskReader, TaskWriter
|
||||
|
||||
|
||||
def build_reader(cfg: TasksConfig) -> TaskReader:
|
||||
"""Dispatch on `cfg.provider` to a concrete `TaskReader`."""
|
||||
"""Dispatch on `cfg.provider` to a concrete `TaskReader`. For
|
||||
`superproductivity` the concrete class also depends on
|
||||
`access` (Gitea #133): `"api"` reads the live local REST API,
|
||||
`"snapshot"` reads the backup file - never both, never a fallback."""
|
||||
if cfg.provider == "superproductivity":
|
||||
from chemenu.tasks import superproductivity as sp
|
||||
|
||||
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
|
||||
if sp_cfg.access == sp.ACCESS_API:
|
||||
return sp.SuperProductivityApiReader(sp_cfg)
|
||||
return sp.SuperProductivityReader(sp_cfg)
|
||||
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
|
||||
|
||||
@@ -37,10 +42,23 @@ def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter:
|
||||
"""Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an
|
||||
already-built `reader` - a writer that needs to re-check the read path
|
||||
(e.g. `SuperProductivityWriter`'s own collision preflight) reads through
|
||||
the same object its caller does, rather than opening a second one."""
|
||||
the same object its caller does, rather than opening a second one.
|
||||
|
||||
For `superproductivity`, a writer exists only when `access: "api"`
|
||||
(Gitea #133): on `access: "snapshot"` the tracker is read-only from here
|
||||
by construction, so this raises `ValidationError` rather than returning a
|
||||
writer that could never do anything - the same posture as "no writer is
|
||||
wired up for this provider at all", just scoped to one access mode of
|
||||
one provider instead of the whole provider."""
|
||||
if cfg.provider == "superproductivity":
|
||||
from chemenu.tasks import superproductivity as sp
|
||||
|
||||
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
|
||||
if sp_cfg.access != sp.ACCESS_API:
|
||||
raise ValidationError(
|
||||
"superproductivity: the tracker is read-only from here (access: "
|
||||
f"'{sp_cfg.access}') - the write path only exists on an access: 'api' "
|
||||
"instance (Gitea #133)."
|
||||
)
|
||||
return sp.SuperProductivityWriter(sp_cfg, reader)
|
||||
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
|
||||
@@ -66,8 +66,9 @@ def read_config(root: "Any") -> "TasksConfig | None":
|
||||
expected = (
|
||||
'{"schema": 1, "provider": "superproductivity", '
|
||||
'"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, '
|
||||
'"someday_stale_months": 5}, "superproductivity": {"backups_dir": "...", '
|
||||
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}}'
|
||||
'"someday_stale_months": 5}, "superproductivity": {"access": "api", '
|
||||
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."} '
|
||||
'(or {"access": "snapshot", "backups_dir": "..."} - see INSTALL.md)}'
|
||||
)
|
||||
try:
|
||||
provider = str(data["provider"])
|
||||
|
||||
@@ -15,7 +15,19 @@ waiting-for item; the person stays in the title's free text), and which
|
||||
someday/maybe items exist and when they last moved. None of this is cached
|
||||
here - a `TaskReader` re-reads on every call, so a caller checking `verify()`
|
||||
after a human's out-of-band step (see `chemenu.errors.HumanInterventionRequired`)
|
||||
sees the current state, not a snapshot from before that step.
|
||||
never sees a value this process cached from before that step.
|
||||
|
||||
**Re-reading is not the same as reading the current state** (Gitea #134,
|
||||
resolved by #133's design rather than by a fix here): a point-in-time source
|
||||
- Super Productivity's `access: "snapshot"`, say - re-reads the *latest file
|
||||
on disk* on every call, which is only as current as that file's own age; a
|
||||
caller's `verify()` can still answer "not yet" against a step that already
|
||||
happened, if nothing has written a fresher file since. Only a genuinely live
|
||||
source - `access: "api"` - re-reads the actual current state. A `TaskReader`
|
||||
that is not always live should say so through `source()`
|
||||
(`chemenu.tasks.protocol.ReadSource`), so a caller can tell "re-read, but
|
||||
possibly stale" apart from "re-read, and current" instead of assuming the
|
||||
stronger claim for every provider.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -43,12 +55,36 @@ class WaitingItem:
|
||||
Faelligkeitsdatum") - a provider that has no separate concept for this
|
||||
must not fall back to reusing the due date, it must decide it cannot
|
||||
supply the field and leave it `None` instead.
|
||||
|
||||
**This rule binds the concept, not a field's name** (Gitea #135's own
|
||||
correction, after #124's Super Productivity adapter read the wrong field
|
||||
under this exact rule): a provider whose own vocabulary does not line up
|
||||
with "due date" - a field called `due*` that actually means scheduling
|
||||
rather than a deadline, say - must be checked against what its
|
||||
documentation says the field *means*, not against what its name suggests
|
||||
to an outsider. Getting this backwards produced a real bug: a whole class
|
||||
of tickler (an all-day, notification-free follow-up) silently never
|
||||
counted as `follow_up_at` at all.
|
||||
"""
|
||||
|
||||
title: str
|
||||
follow_up_at: Optional[date]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadSource:
|
||||
"""Where one `TaskReader.source()` call's data came from, for display
|
||||
only (Gitea #133) - `chemenu.review`/`wikitool doctor` show it, no check
|
||||
branches on it. `kind` is provider-defined (e.g. `"api"`/`"snapshot"` for
|
||||
Super Productivity); `detail` is the human-readable line, which for a
|
||||
point-in-time source (a snapshot file, not a live call) names its age -
|
||||
the two access paths can live on different machines and nobody may ever
|
||||
see them side by side, so the answer itself has to say how fresh it is."""
|
||||
|
||||
kind: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenItems:
|
||||
"""A project's momentary open-loop count, plus the subset that is
|
||||
@@ -99,6 +135,15 @@ class TaskReader(Protocol):
|
||||
projects."""
|
||||
...
|
||||
|
||||
def source(self) -> ReadSource:
|
||||
"""Where this reader's data comes from, for display (Gitea #133) -
|
||||
never consulted by a check, only by `chemenu.review`/`wikitool doctor`
|
||||
to say which path answered and, for a point-in-time source, how old
|
||||
it is. Must not perform a network call beyond what answering it
|
||||
cheaply requires - a provider whose read path is always live can
|
||||
answer this without touching the network at all."""
|
||||
...
|
||||
|
||||
|
||||
class TaskWriter(Protocol):
|
||||
"""The write path a provider adapter offers only if it actually can
|
||||
|
||||
@@ -1,57 +1,87 @@
|
||||
"""The Super Productivity adapter (Gitea #124, #119 D2/D8/D9/D30).
|
||||
"""The Super Productivity adapter (Gitea #124, #133, #135; #119 D2/D8/D9/D30).
|
||||
|
||||
Read and write deliberately use different transports (#124's own design note,
|
||||
carried as its first acceptance criterion): the read path is headless file
|
||||
access, the write path is Super Productivity's own local REST API - so a
|
||||
review can run with the app closed, and a create only needs the app open when
|
||||
it actually has to ask it for something.
|
||||
Read and write access are chosen **per instance, explicitly, exclusively**
|
||||
(Gitea #133): a headless-operated instance sets `access: "snapshot"` and only
|
||||
ever reads the periodic backup file on disk; a desktop instance sets
|
||||
`access: "api"` and only ever talks to Super Productivity's own local REST
|
||||
API, which also carries the current live state and the one write call this
|
||||
adapter offers. There is no third value, no default, and no runtime fallback
|
||||
between the two - the config decides once, at read time, which half of this
|
||||
module ever runs.
|
||||
|
||||
## Read path: the backup snapshot, not a live `db.json`
|
||||
## `access: "snapshot"` - the backup file, not a live `db.json`
|
||||
|
||||
Desktop Super Productivity keeps its live state in IndexedDB, not in a flat
|
||||
file called `db.json` on disk - there is no such file to read headlessly.
|
||||
What *does* exist as a plain file is a periodic snapshot: `electron/backup.ts`
|
||||
writes the complete app state as `JSON.stringify(data)` into
|
||||
`<userData>/backups/<timestamp>.json` on every backup, newest-timestamp-last
|
||||
by filename (its own comment: "timestamps sort lexically"). That snapshot's
|
||||
top-level shape is `AppDataComplete`/`AppDataCompleteLegacy`
|
||||
(`src/app/op-log/model/model-config.ts` / `src/app/imex/sync/sync.model.ts`),
|
||||
keyed by feature name - `task`, `project`, `tag`, ... - and this reader only
|
||||
looks at the three keys it needs, each an `@ngrx/entity` `EntityState`
|
||||
(`{"ids": [...], "entities": {...}}`, `packages/plugin-api/src/types.ts`
|
||||
`Task`/`Project`/`Tag`). Verified against the `master` branch of
|
||||
`super-productivity/super-productivity` on 2026-09-19 - unversioned, per
|
||||
#124's own note, so a future release is free to reshape it without warning,
|
||||
which is exactly why every read below fails loudly on a shape it does not
|
||||
recognize rather than guessing.
|
||||
`<userData>/backups/YYYY-MM-DD_HHmmss.json` on every backup - a fixed-width
|
||||
timestamp name, so a lexical sort is also a chronological one, which is what
|
||||
`latest_snapshot_path` relies on. That snapshot's top-level shape is
|
||||
`AppDataComplete`/`AppDataCompleteLegacy` (`src/app/op-log/model/model-config.ts`
|
||||
/ `src/app/imex/sync/sync.model.ts`), keyed by feature name - `task`,
|
||||
`project`, `tag`, ... - and this reader only looks at the three keys it
|
||||
needs, each an `@ngrx/entity` `EntityState` (`{"ids": [...], "entities": {...}}`,
|
||||
`packages/plugin-api/src/types.ts` `Task`/`Project`/`Tag`). Unversioned, per
|
||||
#124's own note, so a future release is free to reshape it without warning -
|
||||
every read below fails loudly on a shape it does not recognize rather than
|
||||
guessing.
|
||||
|
||||
## The two GTD conventions this adapter encodes (#119 D9/D30)
|
||||
## `access: "api"` - the local REST API, the current live state
|
||||
|
||||
The routes live in the renderer, not the Electron main process:
|
||||
`src/app/core/electron/local-rest-api-handler.service.ts` registers
|
||||
`GET /status`, `GET /focus`, `GET|POST /task-control/*`, `GET|POST /tasks`,
|
||||
`GET|PATCH|DELETE /tasks/:id`, `GET /projects`, `GET /tags` - project and tag
|
||||
CRUD do not exist. Every endpoint but `GET /health` requires
|
||||
`Authorization: Bearer <api_token>`; a closed app or an unauthenticated
|
||||
request are not the same failure - `GET /health` answers `503 APP_NOT_READY`
|
||||
when the backend is up but the renderer is not yet, which this adapter
|
||||
surfaces as its own message rather than folding into "unreachable".
|
||||
`GET /projects` runs through `selectUnarchivedProjects` and excludes
|
||||
`isArchived` projects server-side; the snapshot path below does the same
|
||||
filtering itself, so the two access paths agree on that without either one
|
||||
needing to know how the other got there (verified against `master`,
|
||||
2026-09-20).
|
||||
|
||||
## The two GTD conventions this adapter encodes (#119 D9/D30, corrected #135)
|
||||
|
||||
Only the `WAITING` status and `follow_up_at` are machine-readable, and Super
|
||||
Productivity has no native field for either:
|
||||
|
||||
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
|
||||
task. Any other tag is left alone.
|
||||
- **`follow_up_at`** is the task's own `remindAt` (a reminder timestamp) -
|
||||
deliberately not `dueDay`/`dueWithTime` (#119 D9: "ausdruecklich nicht das
|
||||
Faelligkeitsdatum"). A task with no reminder set has no `follow_up_at`, full
|
||||
stop; this adapter never substitutes the due date for it.
|
||||
- **`follow_up_at`** is the task's own *scheduled* date - `dueWithTime` if
|
||||
set, else `dueDay` (`task.model.ts`'s own read rule: "check dueWithTime
|
||||
FIRST"). This is **not** the earlier `remindAt` mapping from #124: `remindAt`
|
||||
only exists when a task is scheduled with a specific time *and* someone
|
||||
asked for a notification, so an all-day, notification-free tickler carried
|
||||
no `follow_up_at` at all under that mapping - a real gap #135 closed.
|
||||
`deadlineDay`/`deadlineWithTime`/`deadlineRemindAt` are Super Productivity's
|
||||
actual due-date fields (its own model docstrings say so) and are never read
|
||||
here (#119 D9 continues to exclude them) - the point of #135's correction
|
||||
is that `due*` was never the thing D9 excludes, whatever the name suggests.
|
||||
A task with neither `dueWithTime` nor `dueDay` has no `follow_up_at`, full
|
||||
stop; this adapter never substitutes the deadline for it.
|
||||
|
||||
## Someday/Maybe: a project's own backlog
|
||||
|
||||
#124 (later, #119) left this as "zu verifizieren": Super Productivity's
|
||||
`ProjectBasicCfg.backlogTaskIds` is exactly this - a second, separate list of
|
||||
task ids per project, apart from the active `taskIds` list a project's board
|
||||
shows. This adapter reads someday/maybe items from `backlogTaskIds`, one
|
||||
project at a time; no tag convention is needed.
|
||||
Super Productivity's `ProjectBasicCfg.backlogTaskIds` is exactly this - a
|
||||
second, separate list of task ids per project, apart from the active
|
||||
`taskIds` list a project's board shows. This adapter reads someday/maybe
|
||||
items from `backlogTaskIds`, one project at a time; no tag convention is
|
||||
needed.
|
||||
|
||||
## Write path: no project-creation endpoint exists
|
||||
## Write path: no project-creation endpoint exists, on either access mode
|
||||
|
||||
The local REST API (`electron/local-rest-api-handler.service.ts`, verified the
|
||||
same day) routes `GET /projects` but has no `POST /projects` at all - task
|
||||
CRUD exists, project CRUD does not. `SuperProductivityWriter.create_project`
|
||||
can therefore not create a project itself; see its docstring and
|
||||
`chemenu.errors.HumanInterventionRequired`.
|
||||
Neither transport routes `POST /projects` - task CRUD exists, project CRUD
|
||||
does not, verified the same day as the rest of this module. So
|
||||
`SuperProductivityWriter.create_project` can never create a project itself
|
||||
regardless of `access`; see its docstring and
|
||||
`chemenu.errors.HumanInterventionRequired`. A writer is offered at all only
|
||||
when `access: "api"` - see `chemenu.tasks.build_writer` - because on
|
||||
`access: "snapshot"` the tracker is read-only from here by construction, not
|
||||
by an extra check bolted onto this module (Gitea #133).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -67,6 +97,7 @@ from chemenu.errors import HumanInterventionRequired, ValidationError
|
||||
from chemenu.tasks.protocol import (
|
||||
OpenItems,
|
||||
ProjectSummary,
|
||||
ReadSource,
|
||||
SomedayItem,
|
||||
WaitingItem,
|
||||
find_project,
|
||||
@@ -79,82 +110,119 @@ WAITING_TAG_TITLE = "waiting"
|
||||
|
||||
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
|
||||
|
||||
_EXPECTED_PROVIDER_CONFIG = (
|
||||
'{"backups_dir": "~/.config/superProductivity/backups", '
|
||||
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
|
||||
' (or "db_path" instead of "backups_dir" to pin one exact file)'
|
||||
ACCESS_API = "api"
|
||||
ACCESS_SNAPSHOT = "snapshot"
|
||||
KNOWN_ACCESS = (ACCESS_API, ACCESS_SNAPSHOT)
|
||||
|
||||
# `electron/backup.ts` writes exactly this shape - a fixed-width timestamp,
|
||||
# no prefix. Restricting the glob to it (Gitea #133) is what keeps a manually
|
||||
# exported file (`sp-backup_*.json` and friends, which sort *after* every
|
||||
# timestamp lexically) from ever being picked as "newest".
|
||||
_SNAPSHOT_GLOB = (
|
||||
"[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_"
|
||||
"[0-9][0-9][0-9][0-9][0-9][0-9].json"
|
||||
)
|
||||
|
||||
_EXPECTED_API_CONFIG = '{"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
|
||||
_EXPECTED_SNAPSHOT_CONFIG = '{"access": "snapshot", "backups_dir": "~/.config/superProductivity/backups"}'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuperProductivityConfig:
|
||||
"""This provider's own section of `.wikitool-tasks.json`
|
||||
(`TasksConfig.provider_config`)."""
|
||||
(`TasksConfig.provider_config`). `access` decides both halves at once -
|
||||
which path is read *and* whether a write path exists at all - and the
|
||||
section carries only the fields that access path uses (Gitea #133): a
|
||||
`snapshot` config with an `api_base_url` in it, or an `api` config with a
|
||||
`backups_dir` in it, is rejected at read time, not ignored."""
|
||||
|
||||
# Exactly one of these two names where to read from. `db_path` wins when
|
||||
# both are set - it names one exact file, which is a stronger statement
|
||||
# than "the newest file in this directory".
|
||||
access: str
|
||||
backups_dir: Optional[Path]
|
||||
db_path: Optional[Path]
|
||||
api_base_url: str
|
||||
# Unused by this module today - nothing it calls needs authentication
|
||||
# (`GET /health` is the one unauthenticated exception, and there is no
|
||||
# write call at all, see the module docstring). Carried through anyway so
|
||||
# a future capability that does need it does not require a config-shape
|
||||
# migration to add it.
|
||||
api_token: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"superproductivity config must be an object. Expected: {_EXPECTED_PROVIDER_CONFIG}"
|
||||
"superproductivity config must be an object. Expected one of: "
|
||||
f"{_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
|
||||
)
|
||||
access = data.get("access")
|
||||
if access not in KNOWN_ACCESS:
|
||||
raise ValidationError(
|
||||
"superproductivity config needs 'access', either 'api' or 'snapshot' - "
|
||||
"required, no default and no fallback between them (Gitea #133). Got: "
|
||||
f"{access!r}. Expected one of: {_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
|
||||
)
|
||||
|
||||
if access == ACCESS_API:
|
||||
extra = sorted(set(data) - {"access", "api_base_url", "api_token"})
|
||||
if extra:
|
||||
raise ValidationError(
|
||||
f"superproductivity config: access: 'api' does not take {extra} - a "
|
||||
"section names only one access path's own fields (Gitea #133). "
|
||||
f"Expected: {_EXPECTED_API_CONFIG}"
|
||||
)
|
||||
api_token = data.get("api_token")
|
||||
if not isinstance(api_token, str) or not api_token:
|
||||
raise ValidationError(
|
||||
"superproductivity config: api_token is required when access: 'api' - "
|
||||
"every endpoint but GET /health requires Authorization: Bearer <token>. "
|
||||
f"Expected: {_EXPECTED_API_CONFIG}"
|
||||
)
|
||||
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
|
||||
return cls(access=access, backups_dir=None, api_base_url=api_base_url, api_token=api_token)
|
||||
|
||||
extra = sorted(set(data) - {"access", "backups_dir"})
|
||||
if extra:
|
||||
raise ValidationError(
|
||||
f"superproductivity config: access: 'snapshot' does not take {extra} - a "
|
||||
"section names only one access path's own fields (Gitea #133). "
|
||||
f"Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
|
||||
)
|
||||
backups_dir = data.get("backups_dir")
|
||||
db_path = data.get("db_path")
|
||||
if backups_dir is None and db_path is None:
|
||||
if not backups_dir:
|
||||
raise ValidationError(
|
||||
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has "
|
||||
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}"
|
||||
"superproductivity config: backups_dir is required when access: 'snapshot' - "
|
||||
f"the read path has nothing to read otherwise. Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
|
||||
)
|
||||
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
|
||||
api_token = data.get("api_token")
|
||||
if api_token is not None and not isinstance(api_token, str):
|
||||
raise ValidationError("superproductivity config: api_token must be a string.")
|
||||
return cls(
|
||||
backups_dir=Path(backups_dir).expanduser() if backups_dir else None,
|
||||
db_path=Path(db_path).expanduser() if db_path else None,
|
||||
api_base_url=api_base_url,
|
||||
api_token=api_token,
|
||||
access=access,
|
||||
backups_dir=Path(backups_dir).expanduser(),
|
||||
api_base_url=DEFAULT_API_BASE_URL,
|
||||
api_token=None,
|
||||
)
|
||||
|
||||
|
||||
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
|
||||
"""The one file to read: `db_path` if given, else the lexically-greatest
|
||||
`*.json` filename under `backups_dir` - the same ordering
|
||||
`electron/backup.ts` itself relies on ("timestamps sort lexically")."""
|
||||
if cfg.db_path is not None:
|
||||
if not cfg.db_path.is_file():
|
||||
raise ValidationError(
|
||||
f"superproductivity: db_path does not exist: {cfg.db_path}"
|
||||
)
|
||||
return cfg.db_path
|
||||
"""The lexically-greatest `YYYY-MM-DD_HHmmss.json` filename under
|
||||
`backups_dir` - the same ordering `electron/backup.ts` writes by
|
||||
construction. Only files matching that exact pattern are candidates
|
||||
(Gitea #133): a manual export (`sp-backup_*.json` and its variants) sorts
|
||||
lexically *after* every timestamp and would otherwise pin every reader to
|
||||
itself forever."""
|
||||
directory = cfg.backups_dir
|
||||
assert directory is not None # from_dict guarantees at least one is set
|
||||
assert directory is not None # from_dict guarantees this for access: snapshot
|
||||
if not directory.is_dir():
|
||||
raise ValidationError(
|
||||
f"superproductivity: backups_dir does not exist: {directory}"
|
||||
)
|
||||
candidates = sorted(directory.glob("*.json"))
|
||||
candidates = sorted(directory.glob(_SNAPSHOT_GLOB))
|
||||
if not candidates:
|
||||
raise ValidationError(
|
||||
f"superproductivity: no *.json backup file found under {directory}. Take a "
|
||||
"backup from Super Productivity (Settings -> Backup & Sync -> Local backups), "
|
||||
"or point db_path/backups_dir at where it actually writes them."
|
||||
f"superproductivity: no timestamped backup file (YYYY-MM-DD_HHmmss.json) found "
|
||||
f"under {directory}. Take a backup from Super Productivity (Settings -> Backup & "
|
||||
"Sync -> Local backups), or point backups_dir at where it actually writes them."
|
||||
)
|
||||
return candidates[-1]
|
||||
|
||||
|
||||
def _snapshot_age_days(path: Path) -> int:
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
||||
return (datetime.now(tz=timezone.utc) - mtime).days
|
||||
|
||||
|
||||
def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]:
|
||||
path = latest_snapshot_path(cfg)
|
||||
try:
|
||||
@@ -194,10 +262,37 @@ def _epoch_ms_to_date(value: Any) -> Optional[date]:
|
||||
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).date()
|
||||
|
||||
|
||||
def _iso_day_to_date(value: Any) -> Optional[date]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _follow_up_at(task: dict) -> Optional[date]:
|
||||
"""`follow_up_at` per #135's corrected mapping: `dueWithTime` first (Super
|
||||
Productivity's own read rule - it takes priority over `dueDay`), else
|
||||
`dueDay`. Never `deadline*` (#119 D9) and never the old `remindAt`."""
|
||||
due_with_time = _epoch_ms_to_date(task.get("dueWithTime"))
|
||||
if due_with_time is not None:
|
||||
return due_with_time
|
||||
return _iso_day_to_date(task.get("dueDay"))
|
||||
|
||||
|
||||
def _is_waiting(task: dict, tags_by_id: dict[str, dict]) -> bool:
|
||||
for tag_id in task.get("tagIds") or []:
|
||||
tag = tags_by_id.get(tag_id)
|
||||
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class SuperProductivityReader:
|
||||
"""`TaskReader` over a Super Productivity backup snapshot. Re-reads the
|
||||
snapshot on every call - see `chemenu.errors.HumanInterventionRequired`
|
||||
for why that matters."""
|
||||
"""`TaskReader` over a Super Productivity backup snapshot
|
||||
(`access: "snapshot"`). Re-reads the snapshot on every call - see
|
||||
`chemenu.errors.HumanInterventionRequired` for why that matters."""
|
||||
|
||||
def __init__(self, cfg: SuperProductivityConfig):
|
||||
self._cfg = cfg
|
||||
@@ -208,6 +303,11 @@ class SuperProductivityReader:
|
||||
projects = _entity_state(snapshot, "project", path)
|
||||
tasks = _entity_state(snapshot, "task", path)
|
||||
tags = _entity_state(snapshot, "tag", path)
|
||||
# Both access paths exclude archived projects (Gitea #133) - the API
|
||||
# does it server-side (`selectUnarchivedProjects`), this path mirrors
|
||||
# it explicitly so the two agree without either knowing about the
|
||||
# other.
|
||||
projects = {pid: p for pid, p in projects.items() if not p.get("isArchived")}
|
||||
return path, projects, tasks, tags
|
||||
|
||||
def projects(self) -> list[ProjectSummary]:
|
||||
@@ -220,13 +320,6 @@ class SuperProductivityReader:
|
||||
for record in projects.values()
|
||||
]
|
||||
|
||||
def _is_waiting(self, task: dict, tags: dict[str, dict]) -> bool:
|
||||
for tag_id in task.get("tagIds") or []:
|
||||
tag = tags.get(tag_id)
|
||||
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
|
||||
return True
|
||||
return False
|
||||
|
||||
def open_items(self, project_name: str) -> OpenItems:
|
||||
_, projects, tasks, tags = self._read()
|
||||
target = normalize_project_name(project_name)
|
||||
@@ -244,12 +337,9 @@ class SuperProductivityReader:
|
||||
if task is None or task.get("isDone"):
|
||||
continue
|
||||
count += 1
|
||||
if self._is_waiting(task, tags):
|
||||
if _is_waiting(task, tags):
|
||||
waiting.append(
|
||||
WaitingItem(
|
||||
title=str(task.get("title", "")),
|
||||
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
|
||||
)
|
||||
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
|
||||
)
|
||||
return OpenItems(count=count, waiting=tuple(waiting))
|
||||
|
||||
@@ -269,6 +359,14 @@ class SuperProductivityReader:
|
||||
)
|
||||
return items
|
||||
|
||||
def source(self) -> ReadSource:
|
||||
path = latest_snapshot_path(self._cfg)
|
||||
age = _snapshot_age_days(path)
|
||||
return ReadSource(
|
||||
kind=ACCESS_SNAPSHOT,
|
||||
detail=f"snapshot {path.name}, {age} day(s) old",
|
||||
)
|
||||
|
||||
|
||||
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
|
||||
"""Whether the local REST API answers `GET /health` right now - the one
|
||||
@@ -283,11 +381,139 @@ def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _expect_list(value: Any, what: str) -> list[dict]:
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise ValidationError(
|
||||
f"superproductivity: API {what} did not return a list of objects - the response "
|
||||
"shape does not match what this adapter expects."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class _ApiClient:
|
||||
"""The one HTTP transport `SuperProductivityApiReader` uses - a thin,
|
||||
loudly-failing wrapper, not a general REST client."""
|
||||
|
||||
def __init__(self, cfg: SuperProductivityConfig):
|
||||
self._cfg = cfg
|
||||
|
||||
def get(self, path: str, *, timeout: float = 10.0) -> Any:
|
||||
url = self._cfg.api_base_url.rstrip("/") + path
|
||||
request = urllib.request.Request(
|
||||
url, headers={"Authorization": f"Bearer {self._cfg.api_token}"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 503:
|
||||
raise ValidationError(
|
||||
"superproductivity: API answered 503 APP_NOT_READY for "
|
||||
f"{path} - the app's backend is up but its renderer is not ready yet. "
|
||||
"Wait a moment and retry."
|
||||
) from exc
|
||||
raise ValidationError(
|
||||
f"superproductivity: API returned HTTP {exc.code} for {path}."
|
||||
) from exc
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise ValidationError(
|
||||
f"superproductivity: API not reachable at {self._cfg.api_base_url} ({exc}). "
|
||||
"Is Super Productivity running?"
|
||||
) from exc
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValidationError(
|
||||
f"superproductivity: API returned unparseable JSON for {path}."
|
||||
) from exc
|
||||
|
||||
|
||||
class SuperProductivityApiReader:
|
||||
"""`TaskReader` over the local REST API (`access: "api"`) - the current
|
||||
live state, re-fetched on every call. `GET /projects` already excludes
|
||||
archived projects server-side; open items are counted against
|
||||
`project.taskIds` rather than `GET /tasks?projectId=`, because a subtask
|
||||
inherits its parent's `projectId` and would otherwise be double-counted
|
||||
against that filter (Gitea #133) - the same source of truth
|
||||
`SuperProductivityReader` uses on the snapshot side."""
|
||||
|
||||
def __init__(self, cfg: SuperProductivityConfig):
|
||||
self._cfg = cfg
|
||||
self._client = _ApiClient(cfg)
|
||||
|
||||
def _read(self) -> tuple[list[dict], dict[str, dict], dict[str, dict]]:
|
||||
projects = _expect_list(self._client.get("/projects"), "/projects")
|
||||
tasks = _expect_list(self._client.get("/tasks"), "/tasks")
|
||||
tags = _expect_list(self._client.get("/tags"), "/tags")
|
||||
# `GET /projects` already runs through `selectUnarchivedProjects`
|
||||
# server-side - filtered again here so both access paths hold the
|
||||
# same guarantee (Gitea #133) rather than one of them trusting the
|
||||
# other end to have done it.
|
||||
projects = [p for p in projects if not p.get("isArchived")]
|
||||
tasks_by_id = {t["id"]: t for t in tasks if isinstance(t.get("id"), str)}
|
||||
tags_by_id = {t["id"]: t for t in tags if isinstance(t.get("id"), str)}
|
||||
return projects, tasks_by_id, tags_by_id
|
||||
|
||||
def projects(self) -> list[ProjectSummary]:
|
||||
projects, _, _ = self._read()
|
||||
return [
|
||||
ProjectSummary(name=str(p.get("title", "")), created=_epoch_ms_to_date(p.get("created")))
|
||||
for p in projects
|
||||
]
|
||||
|
||||
def open_items(self, project_name: str) -> OpenItems:
|
||||
projects, tasks_by_id, tags_by_id = self._read()
|
||||
target = normalize_project_name(project_name)
|
||||
project = next(
|
||||
(p for p in projects if normalize_project_name(str(p.get("title", ""))) == target), None
|
||||
)
|
||||
if project is None:
|
||||
return OpenItems(count=0, waiting=())
|
||||
|
||||
waiting: list[WaitingItem] = []
|
||||
count = 0
|
||||
for task_id in project.get("taskIds") or []:
|
||||
task = tasks_by_id.get(task_id)
|
||||
if task is None or task.get("isDone"):
|
||||
continue
|
||||
count += 1
|
||||
if _is_waiting(task, tags_by_id):
|
||||
waiting.append(
|
||||
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
|
||||
)
|
||||
return OpenItems(count=count, waiting=tuple(waiting))
|
||||
|
||||
def someday_items(self) -> list[SomedayItem]:
|
||||
projects, tasks_by_id, _ = self._read()
|
||||
items: list[SomedayItem] = []
|
||||
for project in projects:
|
||||
for task_id in project.get("backlogTaskIds") or []:
|
||||
task = tasks_by_id.get(task_id)
|
||||
if task is None or task.get("isDone"):
|
||||
continue
|
||||
items.append(
|
||||
SomedayItem(
|
||||
title=str(task.get("title", "")),
|
||||
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
def source(self) -> ReadSource:
|
||||
# Static by construction - every call above re-fetches, so there is
|
||||
# nothing "live" needs to check first, and no network call is spent
|
||||
# just to answer this.
|
||||
return ReadSource(kind=ACCESS_API, detail="live (local REST API)")
|
||||
|
||||
|
||||
class SuperProductivityWriter:
|
||||
"""`TaskWriter` over the local REST API - except there is no API call
|
||||
this can actually make, see the module docstring."""
|
||||
this can actually make, see the module docstring. Only offered by
|
||||
`chemenu.tasks.build_writer` when `access: "api"` (Gitea #133) - on
|
||||
`access: "snapshot"` the tracker is read-only from here, and that refusal
|
||||
happens before this class is ever constructed."""
|
||||
|
||||
def __init__(self, cfg: SuperProductivityConfig, reader: SuperProductivityReader):
|
||||
def __init__(self, cfg: SuperProductivityConfig, reader):
|
||||
self._cfg = cfg
|
||||
self._reader = reader
|
||||
|
||||
|
||||
@@ -371,6 +371,8 @@ def test_tasks_provider_fails_on_a_bad_superproductivity_section(instance):
|
||||
|
||||
|
||||
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance):
|
||||
"""`access: snapshot` - only the read path is a finding; the API is not
|
||||
even attempted (Gitea #133)."""
|
||||
backups = config.ROOT / "sp-backups"
|
||||
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
|
||||
json.dumps({
|
||||
@@ -379,14 +381,35 @@ def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(insta
|
||||
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
||||
"someday_stale_months": 5,
|
||||
},
|
||||
"superproductivity": {"backups_dir": str(backups)},
|
||||
"superproductivity": {"access": "snapshot", "backups_dir": str(backups)},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tasks-provider") == "OK"
|
||||
detail = _detail(checks, "tasks-provider")
|
||||
assert "access=snapshot" in detail
|
||||
assert "not ready" in detail
|
||||
|
||||
|
||||
def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance):
|
||||
"""`access: api` - the API reachability is the finding; the (irrelevant)
|
||||
backup file is not even looked at (Gitea #133)."""
|
||||
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
|
||||
json.dumps({
|
||||
"schema": 1, "provider": "superproductivity",
|
||||
"thresholds": {
|
||||
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
||||
"someday_stale_months": 5,
|
||||
},
|
||||
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1", "api_token": "t"},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tasks-provider") == "OK"
|
||||
detail = _detail(checks, "tasks-provider")
|
||||
assert "access=api" in detail
|
||||
assert "not reachable" in detail
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import contextlib
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from chemenu.commands._util import coerce_set_value, parse_set_fields
|
||||
from chemenu.commands.new_page import _page_subdir
|
||||
@@ -12,34 +16,50 @@ _TASKS_THRESHOLDS = {
|
||||
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
|
||||
}
|
||||
|
||||
_API_TOKEN = "test-token"
|
||||
|
||||
def _write_tasks_config(root, db_path) -> None:
|
||||
|
||||
def _write_tasks_config(root, base_url: str) -> None:
|
||||
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
|
||||
provider at `db_path` - same shape `test_review.py`'s own helper writes,
|
||||
duplicated locally rather than imported since test files in this suite
|
||||
do not import each other (see `instructions/dev/testing-conventions.md`
|
||||
for the isolation this mirrors one layer up)."""
|
||||
provider's `access: "api"` path at `base_url` (Gitea #133) - `new
|
||||
project` only ever has a write path on that access mode. Duplicated
|
||||
locally rather than imported since test files in this suite do not
|
||||
import each other (see `instructions/dev/testing-conventions.md` for the
|
||||
isolation this mirrors one layer up)."""
|
||||
(root / ".wikitool-tasks.json").write_text(
|
||||
json.dumps({
|
||||
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
|
||||
"superproductivity": {"db_path": str(db_path)},
|
||||
"superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_sp_snapshot(root, project_titles: list[str]):
|
||||
"""A Super Productivity backup snapshot whose `project` entity state
|
||||
holds one project per title in `project_titles` and nothing else -
|
||||
enough for `find_project`/`create_project`'s own preflight, which is all
|
||||
`new project`'s tracker step reads or writes (it never calls
|
||||
`open_items`/`someday_items`)."""
|
||||
db_path = root / "db.json"
|
||||
def _write_tasks_config_snapshot(root, backups_dir) -> None:
|
||||
"""The other access path (Gitea #133) - used only by the tests below
|
||||
that confirm `new project` refuses entirely against it."""
|
||||
(root / ".wikitool-tasks.json").write_text(
|
||||
json.dumps({
|
||||
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
|
||||
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_sp_backup(root, project_titles: list[str]) -> Path:
|
||||
"""A Super Productivity backup snapshot, named the way `electron/backup.ts`
|
||||
actually names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133), whose `project`
|
||||
entity state holds one project per title in `project_titles` and nothing
|
||||
else. Used only by the `access: "snapshot"` refusal tests - that path
|
||||
never reaches a tracker write regardless of what this file contains."""
|
||||
backups_dir = root / "backups"
|
||||
backups_dir.mkdir(exist_ok=True)
|
||||
projects = {
|
||||
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
|
||||
for i, title in enumerate(project_titles)
|
||||
}
|
||||
db_path.write_text(
|
||||
(backups_dir / "2026-01-01_000000.json").write_text(
|
||||
json.dumps({
|
||||
"project": {"ids": list(projects), "entities": projects},
|
||||
"task": {"ids": [], "entities": {}},
|
||||
@@ -47,7 +67,69 @@ def _write_sp_snapshot(root, project_titles: list[str]):
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return db_path
|
||||
return backups_dir
|
||||
|
||||
|
||||
def _make_api_handler(state: dict):
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
posted = False
|
||||
|
||||
def do_GET(self): # noqa: N802 - stdlib method name
|
||||
if self.path == "/health":
|
||||
self._reply(200, {"ok": True})
|
||||
return
|
||||
if self.headers.get("Authorization") != f"Bearer {_API_TOKEN}":
|
||||
self._reply(401, {"error": "unauthorized"})
|
||||
return
|
||||
if self.path == "/projects":
|
||||
self._reply(200, state["projects"])
|
||||
return
|
||||
if self.path in ("/tasks", "/tags"):
|
||||
self._reply(200, [])
|
||||
return
|
||||
self._reply(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
# `new project` must never attempt this on the API path either -
|
||||
# there is no POST /projects endpoint upstream (Gitea #124/#133).
|
||||
Handler.posted = True
|
||||
self._reply(404, {"error": "no such endpoint (fixture)"})
|
||||
|
||||
def _reply(self, code: int, payload) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args): # silence stderr noise during the test run
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _api_server(project_titles: list[str]):
|
||||
"""A stub Super Productivity local REST API serving just enough of
|
||||
`GET /projects`/`/tasks`/`/tags` for `find_project`/`create_project`'s
|
||||
own preflight - the only thing `new project`'s tracker step reads."""
|
||||
state = {"projects": [
|
||||
{"id": f"p{i}", "title": title, "created": 1700000000000, "taskIds": [], "backlogTaskIds": []}
|
||||
for i, title in enumerate(project_titles)
|
||||
]}
|
||||
handler_cls = _make_api_handler(state)
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server, handler_cls
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def _base_url(server: http.server.HTTPServer) -> str:
|
||||
return f"http://127.0.0.1:{server.server_address[1]}"
|
||||
|
||||
|
||||
def _invoke_new(monkeypatch, kb_dir, args):
|
||||
@@ -174,33 +256,35 @@ def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch,
|
||||
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
|
||||
"""Super Productivity can never create a project itself (Gitea #124) -
|
||||
the first attempt against a free name must exit 42 and create nothing on
|
||||
either side."""
|
||||
either side, and never attempt a POST (Gitea #133: no such endpoint
|
||||
exists on the API path either)."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, [])
|
||||
_write_tasks_config(root, db_path)
|
||||
with _api_server([]) as (server, handler_cls):
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 42
|
||||
assert "NEEDS USER CLEARANCE" in result.output
|
||||
assert "--resume" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 42
|
||||
assert "NEEDS USER CLEARANCE" in result.output
|
||||
assert "--resume" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
assert handler_cls.posted is False
|
||||
|
||||
|
||||
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
|
||||
"""The tracker already has this name (case-insensitively) and --resume
|
||||
was not passed - #126's AC: refuse, name the collision, create nothing."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, ["kueche renovieren"])
|
||||
_write_tasks_config(root, db_path)
|
||||
with _api_server(["kueche renovieren"]) as (server, _handler_cls):
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypatch, kb_dir):
|
||||
@@ -209,15 +293,15 @@ def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypat
|
||||
re-run with --resume must verify it via the read path and continue to
|
||||
page creation instead of treating it as a collision."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
|
||||
_write_tasks_config(root, db_path)
|
||||
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already existed" in result.output
|
||||
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already existed" in result.output
|
||||
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monkeypatch, kb_dir):
|
||||
@@ -226,13 +310,45 @@ def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monke
|
||||
message again, not a silent pass-through (#126's own wording: "wirft das
|
||||
Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, [])
|
||||
_write_tasks_config(root, db_path)
|
||||
with _api_server([]) as (server, _handler_cls):
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 42
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_project_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
|
||||
"""Gitea #133: `access: "snapshot"` is read-only from here - `new
|
||||
project` creates neither a tracker project nor a page and exits 1, not
|
||||
42 (nothing is waiting on a human's clearance, the command simply cannot
|
||||
do this from a snapshot instance), pointing at an access: "api" one."""
|
||||
root = kb_dir.parent
|
||||
backups_dir = _write_sp_backup(root, [])
|
||||
_write_tasks_config_snapshot(root, backups_dir)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "access: 'api'" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_project_resume_also_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
|
||||
"""--resume changes nothing about the snapshot-access refusal (Gitea
|
||||
#133's own AC) - even against a tracker that already has the project."""
|
||||
root = kb_dir.parent
|
||||
backups_dir = _write_sp_backup(root, ["Kueche renovieren"])
|
||||
_write_tasks_config_snapshot(root, backups_dir)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 42
|
||||
assert result.exit_code == 1
|
||||
assert "access: 'api'" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
@@ -253,17 +369,18 @@ def test_new_project_never_leaves_only_the_page_when_the_write_fails(monkeypatch
|
||||
import chemenu.commands.new_page as new_page
|
||||
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
|
||||
_write_tasks_config(root, db_path)
|
||||
|
||||
def _boom(path, frontmatter, body):
|
||||
raise OSError("disk full (fixture)")
|
||||
|
||||
monkeypatch.setattr(new_page, "write_page", _boom)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "already confirmed to exist" in " ".join(result.output.split())
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
@@ -51,8 +51,14 @@ def _entity_state(records: dict[str, dict]) -> dict:
|
||||
|
||||
|
||||
def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path:
|
||||
db_path = root / "db.json"
|
||||
db_path.write_text(
|
||||
"""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),
|
||||
@@ -60,16 +66,16 @@ def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return db_path
|
||||
return backups_dir
|
||||
|
||||
|
||||
def _write_tasks_config(root: Path, db_path: Path, thresholds: dict | None = None) -> None:
|
||||
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": {"db_path": str(db_path)},
|
||||
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -110,8 +116,8 @@ def _tree(root: Path) -> dict[str, bytes]:
|
||||
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": []}}
|
||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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)
|
||||
@@ -131,10 +137,10 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
|
||||
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"], "remindAt": _ms(2026, month, remind_day)}}
|
||||
"tagIds": ["tag-wait"], "dueWithTime": _ms(2026, month, remind_day)}}
|
||||
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
|
||||
db_path = _write_snapshot(tmp_path, projects, tasks, tags)
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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)
|
||||
@@ -142,6 +148,23 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
|
||||
assert fired == should_fire
|
||||
|
||||
|
||||
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 ----------------------------------------
|
||||
|
||||
|
||||
@@ -153,8 +176,8 @@ def test_check3_unpaged_project_age_threshold(tmp_path, created_year_month_day,
|
||||
y, m, d = created_year_month_day
|
||||
projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d),
|
||||
"taskIds": [], "backlogTaskIds": []}}
|
||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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)
|
||||
@@ -168,8 +191,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
|
||||
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": []}}
|
||||
db_path = _write_snapshot(tmp_path, projects, tasks, {})
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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)
|
||||
@@ -181,8 +204,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
|
||||
|
||||
|
||||
def test_check4_fires_when_no_tracker_project_exists(tmp_path):
|
||||
db_path = _write_snapshot(tmp_path, {}, {}, {})
|
||||
_write_tasks_config(tmp_path, db_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)
|
||||
@@ -199,8 +222,8 @@ def test_a_run_touches_no_file(tmp_path):
|
||||
"taskIds": [], "backlogTaskIds": ["t3"]}}
|
||||
tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [],
|
||||
"updated": _ms(2020, 1, 1)}}
|
||||
db_path = _write_snapshot(tmp_path, projects, tasks, {})
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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)
|
||||
@@ -221,8 +244,8 @@ def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire):
|
||||
"taskIds": [], "backlogTaskIds": ["t3"]}}
|
||||
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
|
||||
"tagIds": [], "updated": _ms(y, m, 1)}}
|
||||
db_path = _write_snapshot(tmp_path, projects, tasks, {})
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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)
|
||||
@@ -239,10 +262,10 @@ def test_no_tasks_config_is_a_clear_validation_error(tmp_path):
|
||||
|
||||
|
||||
def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path):
|
||||
"""A db_path that does not exist is the unreachable-provider case: checks
|
||||
"""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.json")
|
||||
_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)
|
||||
@@ -275,7 +298,7 @@ def test_cli_incomplete_report_exits_1_and_still_prints(tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
_write_tasks_config(tmp_path, tmp_path / "does-not-exist.json")
|
||||
_write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
|
||||
(tmp_path / "kb" / "gtd").mkdir(parents=True)
|
||||
|
||||
result = runner.invoke(app, ["review"])
|
||||
@@ -294,8 +317,8 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||
"taskIds": [], "backlogTaskIds": []}}
|
||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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"])
|
||||
@@ -321,6 +344,39 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -339,8 +395,8 @@ def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
|
||||
|
||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||
"taskIds": [], "backlogTaskIds": []}}
|
||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
||||
_write_tasks_config(tmp_path, db_path)
|
||||
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)
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124).
|
||||
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124, #133, #135).
|
||||
|
||||
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`).
|
||||
`super-productivity/super-productivity`'s `master` branch: a flat top-level
|
||||
object with `project`/`task`/`tag` as `@ngrx/entity` `{"ids": [...],
|
||||
"entities": {...}}` maps for the snapshot path, and the same records as a
|
||||
flat list (the local REST API's own shape) for the API path (see the module
|
||||
docstring for the exact source files). No test here starts a real Super
|
||||
Productivity instance or touches anything beyond a loopback socket and
|
||||
`tmp_path` (`instructions/dev/testing-conventions.md`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -55,7 +57,7 @@ def _snapshot() -> dict:
|
||||
"projectId": "p1",
|
||||
"isDone": False,
|
||||
"tagIds": ["tag-wait"],
|
||||
"remindAt": _ms(2026, 3, 1),
|
||||
"dueWithTime": _ms(2026, 3, 1),
|
||||
},
|
||||
"t2": {
|
||||
"id": "t2",
|
||||
@@ -94,73 +96,115 @@ 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")
|
||||
|
||||
|
||||
def _minimal_snapshot(task_extra: dict) -> dict:
|
||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||
"taskIds": ["t1"], "backlogTaskIds": []}}
|
||||
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False,
|
||||
"tagIds": ["tag-wait"], **task_extra}}
|
||||
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
|
||||
return {"project": _entity_state(projects), "task": _entity_state(tasks), "tag": _entity_state(tags)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(tmp_path) -> sp.SuperProductivityConfig:
|
||||
db_path = tmp_path / "db.json"
|
||||
_write_snapshot(db_path)
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
_write_snapshot(backups_dir / "2026-03-01_120000.json")
|
||||
return sp.SuperProductivityConfig(
|
||||
backups_dir=None, db_path=db_path, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None
|
||||
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
|
||||
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||
)
|
||||
|
||||
|
||||
# --- from_dict ---------------------------------------------------------------
|
||||
# --- 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_requires_access():
|
||||
with pytest.raises(ValidationError, match="access"):
|
||||
sp.SuperProductivityConfig.from_dict({"backups_dir": "/x"})
|
||||
|
||||
|
||||
def test_from_dict_defaults_the_api_base_url():
|
||||
cfg_ = sp.SuperProductivityConfig.from_dict({"db_path": "/x/db.json"})
|
||||
def test_from_dict_rejects_an_unknown_access_value():
|
||||
with pytest.raises(ValidationError, match="access"):
|
||||
sp.SuperProductivityConfig.from_dict({"access": "auto", "backups_dir": "/x"})
|
||||
|
||||
|
||||
def test_from_dict_api_requires_a_token():
|
||||
with pytest.raises(ValidationError, match="api_token"):
|
||||
sp.SuperProductivityConfig.from_dict({"access": "api"})
|
||||
|
||||
|
||||
def test_from_dict_api_defaults_the_base_url():
|
||||
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "api", "api_token": "t"})
|
||||
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
|
||||
assert cfg_.access == sp.ACCESS_API
|
||||
|
||||
|
||||
def test_from_dict_expands_user_in_paths(monkeypatch):
|
||||
def test_from_dict_api_rejects_a_snapshot_field():
|
||||
with pytest.raises(ValidationError, match="backups_dir"):
|
||||
sp.SuperProductivityConfig.from_dict(
|
||||
{"access": "api", "api_token": "t", "backups_dir": "/x"}
|
||||
)
|
||||
|
||||
|
||||
def test_from_dict_snapshot_requires_backups_dir():
|
||||
with pytest.raises(ValidationError, match="backups_dir"):
|
||||
sp.SuperProductivityConfig.from_dict({"access": "snapshot"})
|
||||
|
||||
|
||||
def test_from_dict_snapshot_rejects_an_api_field():
|
||||
with pytest.raises(ValidationError, match="api_token"):
|
||||
sp.SuperProductivityConfig.from_dict(
|
||||
{"access": "snapshot", "backups_dir": "/x", "api_token": "t"}
|
||||
)
|
||||
|
||||
|
||||
def test_from_dict_snapshot_expands_user_in_backups_dir(monkeypatch):
|
||||
monkeypatch.setenv("HOME", "/home/fixture")
|
||||
cfg_ = sp.SuperProductivityConfig.from_dict({"backups_dir": "~/backups"})
|
||||
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "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,
|
||||
)
|
||||
def test_from_dict_rejects_db_path_entirely():
|
||||
"""`db_path` was #133's own casualty - it must not silently work as an
|
||||
alias for `backups_dir` under either access mode."""
|
||||
with pytest.raises(ValidationError):
|
||||
sp.latest_snapshot_path(cfg_)
|
||||
sp.SuperProductivityConfig.from_dict({"access": "snapshot", "db_path": "/x/db.json"})
|
||||
|
||||
|
||||
# --- latest_snapshot_path / schema drift ---------------------------------------
|
||||
|
||||
def test_backups_dir_must_exist(tmp_path):
|
||||
cfg_ = sp.SuperProductivityConfig(
|
||||
backups_dir=tmp_path / "nope", db_path=None,
|
||||
access=sp.ACCESS_SNAPSHOT, backups_dir=tmp_path / "nope",
|
||||
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):
|
||||
def test_backups_dir_with_no_timestamped_file_is_an_error(tmp_path):
|
||||
backups = tmp_path / "backups"
|
||||
backups.mkdir()
|
||||
(backups / "sp-backup_2026-01-01.json").write_text("{}", encoding="utf-8")
|
||||
cfg_ = sp.SuperProductivityConfig(
|
||||
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
|
||||
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):
|
||||
def test_backups_dir_picks_the_lexically_latest_timestamped_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)
|
||||
_write_snapshot(backups / "2026-01-01_000000.json", older)
|
||||
_write_snapshot(backups / "2026-02-01_000000.json", newer)
|
||||
cfg_ = sp.SuperProductivityConfig(
|
||||
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
|
||||
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||
)
|
||||
reader = sp.SuperProductivityReader(cfg_)
|
||||
names = {p.name for p in reader.projects()}
|
||||
@@ -168,10 +212,31 @@ def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
|
||||
assert "Old Snapshot Project" not in names
|
||||
|
||||
|
||||
def test_a_manual_export_never_wins_even_placed_next_to_an_older_timestamp(tmp_path):
|
||||
"""`sp-backup_*.json` sorts lexically *after* every timestamp - the
|
||||
hardened glob (Gitea #133) must never pick it, whatever its own name or
|
||||
mtime looks like next to the real backups."""
|
||||
backups = tmp_path / "backups"
|
||||
backups.mkdir()
|
||||
timestamped = _snapshot()
|
||||
manual_export = _snapshot()
|
||||
manual_export["project"]["entities"]["p1"]["title"] = "From Manual Export"
|
||||
_write_snapshot(backups / "2026-01-01_000000.json", timestamped)
|
||||
_write_snapshot(backups / "sp-backup_2026-06-01.json", manual_export)
|
||||
cfg_ = sp.SuperProductivityConfig(
|
||||
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
|
||||
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 "From Manual Export" 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)
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
with pytest.raises(ValidationError, match="task"):
|
||||
reader.projects()
|
||||
@@ -180,13 +245,13 @@ def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
|
||||
def test_missing_top_level_key_fails_loud(cfg):
|
||||
data = _snapshot()
|
||||
del data["project"]
|
||||
_write_snapshot(cfg.db_path, data)
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
with pytest.raises(ValidationError, match="project"):
|
||||
reader.projects()
|
||||
|
||||
|
||||
# --- read path ----------------------------------------------------------------
|
||||
# --- read path (snapshot) -------------------------------------------------------
|
||||
|
||||
def test_projects_lists_name_and_created(cfg):
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
@@ -195,13 +260,23 @@ def test_projects_lists_name_and_created(cfg):
|
||||
assert by_name["Kueche renovieren"].created == date(2026, 2, 1)
|
||||
|
||||
|
||||
def test_projects_excludes_archived(cfg):
|
||||
data = _snapshot()
|
||||
data["project"]["entities"]["p1"]["isArchived"] = True
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
names = {p.name for p in reader.projects()}
|
||||
assert "Ship Chemenu 7.0" not in names
|
||||
assert "Kueche renovieren" in names
|
||||
|
||||
|
||||
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):
|
||||
def test_open_items_reports_waiting_with_follow_up_at_from_due_with_time(cfg):
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
result = reader.open_items("Ship Chemenu 7.0")
|
||||
assert len(result.waiting) == 1
|
||||
@@ -230,7 +305,49 @@ def test_someday_items_come_from_backlog_task_ids_only(cfg):
|
||||
assert items[0].modified == date(2026, 1, 15)
|
||||
|
||||
|
||||
# --- write path: HumanInterventionRequired -----------------------------------
|
||||
def test_source_names_the_snapshot_file(cfg):
|
||||
source = sp.SuperProductivityReader(cfg).source()
|
||||
assert source.kind == sp.ACCESS_SNAPSHOT
|
||||
assert "2026-03-01_120000.json" in source.detail
|
||||
|
||||
|
||||
# --- follow_up_at mapping (Gitea #135) -------------------------------------------
|
||||
|
||||
def test_follow_up_at_reads_due_with_time(cfg):
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueWithTime": _ms(2026, 3, 1)}))
|
||||
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||
assert waiting[0].follow_up_at == date(2026, 3, 1)
|
||||
|
||||
|
||||
def test_follow_up_at_falls_back_to_due_day(cfg):
|
||||
"""The gap #135 closed: an all-day, notification-free tickler has no
|
||||
`dueWithTime` and no (now-removed) `remindAt` at all, and must still be
|
||||
read as a follow-up date."""
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueDay": "2026-03-15"}))
|
||||
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||
assert waiting[0].follow_up_at == date(2026, 3, 15)
|
||||
|
||||
|
||||
def test_follow_up_at_prefers_due_with_time_over_due_day(cfg):
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
|
||||
"dueWithTime": _ms(2026, 3, 1), "dueDay": "2026-04-01",
|
||||
}))
|
||||
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||
assert waiting[0].follow_up_at == date(2026, 3, 1)
|
||||
|
||||
|
||||
def test_follow_up_at_never_reads_remind_at_or_deadline_fields(cfg):
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
|
||||
"remindAt": _ms(2026, 3, 1),
|
||||
"deadlineDay": "2026-03-01",
|
||||
"deadlineWithTime": _ms(2026, 3, 1),
|
||||
"deadlineRemindAt": _ms(2026, 3, 1),
|
||||
}))
|
||||
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||
assert waiting[0].follow_up_at is None
|
||||
|
||||
|
||||
# --- write path: HumanInterventionRequired ---------------------------------------
|
||||
|
||||
def test_create_project_refuses_a_name_collision(cfg):
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
@@ -257,17 +374,17 @@ def test_create_project_asks_a_human_and_verify_reflects_the_read_path(cfg):
|
||||
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
|
||||
"taskIds": [], "backlogTaskIds": [],
|
||||
}
|
||||
_write_snapshot(cfg.db_path, data)
|
||||
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||
|
||||
assert exc.verify() is True
|
||||
|
||||
|
||||
# --- health -------------------------------------------------------------------
|
||||
# --- 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,
|
||||
access=sp.ACCESS_API, backups_dir=None,
|
||||
api_base_url="http://127.0.0.1:1", api_token="t",
|
||||
)
|
||||
assert sp.health(cfg_, timeout=0.5) is False
|
||||
|
||||
@@ -289,10 +406,191 @@ def test_health_is_true_when_the_endpoint_answers(tmp_path):
|
||||
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,
|
||||
access=sp.ACCESS_API, backups_dir=None,
|
||||
api_base_url=f"http://127.0.0.1:{port}", api_token="t",
|
||||
)
|
||||
assert sp.health(cfg_, timeout=2.0) is True
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
# --- the API read path (Gitea #133) -------------------------------------------------
|
||||
|
||||
def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: dict[str, int] | None = None):
|
||||
status_override = status_override or {}
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path == "/health":
|
||||
self._reply(200, {"ok": True})
|
||||
return
|
||||
override = status_override.get(self.path)
|
||||
if override is not None:
|
||||
self._reply(override, {"error": "fixture"})
|
||||
return
|
||||
if self.headers.get("Authorization") != f"Bearer {token}":
|
||||
self._reply(401, {"error": "unauthorized"})
|
||||
return
|
||||
if self.path in routes:
|
||||
self._reply(200, routes[self.path])
|
||||
return
|
||||
self._reply(404, {"error": "not found"})
|
||||
|
||||
def _reply(self, code: int, payload: Any) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args): # noqa: D401 - silence stderr noise
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _api_server(routes: dict[str, Any], *, token: str = "test-token", status_override=None):
|
||||
handler_cls = _make_api_handler(routes, token, status_override=status_override)
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def _api_cfg(server: http.server.HTTPServer, *, token: str = "test-token") -> sp.SuperProductivityConfig:
|
||||
port = server.server_address[1]
|
||||
return sp.SuperProductivityConfig(
|
||||
access=sp.ACCESS_API, backups_dir=None,
|
||||
api_base_url=f"http://127.0.0.1:{port}", api_token=token,
|
||||
)
|
||||
|
||||
|
||||
def _fixture_records() -> tuple[list[dict], list[dict], list[dict]]:
|
||||
projects = [
|
||||
{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||
"taskIds": ["t1", "t2"], "backlogTaskIds": ["t3"]},
|
||||
{"id": "p2", "title": "Archived Project", "created": _ms(2026, 1, 1),
|
||||
"taskIds": [], "backlogTaskIds": [], "isArchived": True},
|
||||
]
|
||||
tasks = [
|
||||
{"id": "t1", "title": "Warte auf Angebot vom Elektriker - Tobias", "isDone": False,
|
||||
"tagIds": ["tag-wait"], "dueDay": "2026-03-15"},
|
||||
# A subtask: inherits projectId from its parent, is NOT itself in
|
||||
# project.taskIds (only t1/t2 are) - counting via project.taskIds
|
||||
# must not pick it up, matching the snapshot path (Gitea #133).
|
||||
{"id": "t2b", "title": "Unterschritt von t1", "isDone": False, "tagIds": [],
|
||||
"projectId": "p1", "parentId": "t1"},
|
||||
{"id": "t2", "title": "Kickoff-Meeting vorbereiten", "isDone": False, "tagIds": []},
|
||||
{"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False, "tagIds": [],
|
||||
"updated": _ms(2026, 1, 15)},
|
||||
]
|
||||
tags = [{"id": "tag-wait", "title": "Waiting"}]
|
||||
return projects, tasks, tags
|
||||
|
||||
|
||||
def test_api_reader_projects_excludes_archived():
|
||||
projects, tasks, tags = _fixture_records()
|
||||
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||
names = {p.name for p in reader.projects()}
|
||||
assert "Ship Chemenu 7.0" in names
|
||||
assert "Archived Project" not in names
|
||||
|
||||
|
||||
def test_api_reader_open_items_counts_against_project_task_ids_not_project_id_filter():
|
||||
projects, tasks, tags = _fixture_records()
|
||||
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||
result = reader.open_items("Ship Chemenu 7.0")
|
||||
assert result.count == 2 # t1, t2 - not the subtask t2b
|
||||
assert len(result.waiting) == 1
|
||||
assert result.waiting[0].follow_up_at == date(2026, 3, 15)
|
||||
|
||||
|
||||
def test_api_reader_someday_items():
|
||||
projects, tasks, tags = _fixture_records()
|
||||
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||
items = reader.someday_items()
|
||||
assert [i.title for i in items] == ["Irgendwann Keller aufraeumen"]
|
||||
|
||||
|
||||
def test_api_reader_401_without_the_right_token_fails_loud():
|
||||
projects, tasks, tags = _fixture_records()
|
||||
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}, token="right-token") as server:
|
||||
cfg_ = _api_cfg(server, token="wrong-token")
|
||||
with pytest.raises(ValidationError):
|
||||
sp.SuperProductivityApiReader(cfg_).projects()
|
||||
|
||||
|
||||
def test_api_reader_503_app_not_ready_is_its_own_message():
|
||||
with _api_server({"/projects": []}, status_override={"/projects": 503}) as server:
|
||||
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||
with pytest.raises(ValidationError, match="APP_NOT_READY"):
|
||||
reader.projects()
|
||||
|
||||
|
||||
def test_api_reader_unreachable_fails_loud_not_silent(tmp_path):
|
||||
cfg_ = sp.SuperProductivityConfig(
|
||||
access=sp.ACCESS_API, backups_dir=None,
|
||||
api_base_url="http://127.0.0.1:1", api_token="t",
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
sp.SuperProductivityApiReader(cfg_).projects()
|
||||
|
||||
|
||||
def test_api_reader_non_list_response_fails_loud():
|
||||
with _api_server({"/projects": {"not": "a list"}}) as server:
|
||||
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||
with pytest.raises(ValidationError):
|
||||
reader.projects()
|
||||
|
||||
|
||||
def test_api_reader_source_is_live_and_needs_no_network():
|
||||
"""`source()` on the API path must not itself perform a request - a
|
||||
static description is correct regardless of reachability (Gitea #133)."""
|
||||
cfg_ = sp.SuperProductivityConfig(
|
||||
access=sp.ACCESS_API, backups_dir=None,
|
||||
api_base_url="http://127.0.0.1:1", api_token="t",
|
||||
)
|
||||
source = sp.SuperProductivityApiReader(cfg_).source()
|
||||
assert source.kind == sp.ACCESS_API
|
||||
|
||||
|
||||
# --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
|
||||
|
||||
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
|
||||
projects, tasks, tags = _fixture_records()
|
||||
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
_write_snapshot(backups_dir / "2026-03-01_000000.json", {
|
||||
"project": _entity_state({p["id"]: p for p in projects}),
|
||||
"task": _entity_state({t["id"]: t for t in tasks}),
|
||||
"tag": _entity_state({t["id"]: t for t in tags}),
|
||||
})
|
||||
snapshot_reader = sp.SuperProductivityReader(sp.SuperProductivityConfig(
|
||||
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
|
||||
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||
))
|
||||
|
||||
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||
api_reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||
|
||||
assert {p.name: p.created for p in snapshot_reader.projects()} == \
|
||||
{p.name: p.created for p in api_reader.projects()}
|
||||
|
||||
snap_items = snapshot_reader.open_items("Ship Chemenu 7.0")
|
||||
api_items = api_reader.open_items("Ship Chemenu 7.0")
|
||||
assert snap_items.count == api_items.count
|
||||
assert [(w.title, w.follow_up_at) for w in snap_items.waiting] == \
|
||||
[(w.title, w.follow_up_at) for w in api_items.waiting]
|
||||
|
||||
assert [(s.title, s.modified) for s in snapshot_reader.someday_items()] == \
|
||||
[(s.title, s.modified) for s in api_reader.someday_items()]
|
||||
@@ -19,8 +19,8 @@ VALID = {
|
||||
"someday_stale_months": 5,
|
||||
},
|
||||
"superproductivity": {
|
||||
"access": "snapshot",
|
||||
"backups_dir": "/tmp/does-not-need-to-exist-for-parsing",
|
||||
"api_base_url": "http://127.0.0.1:3876",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user