131 lines
3.9 KiB
Python
131 lines
3.9 KiB
Python
|
|
"""Regression: `odysseus-calendar list` must select events that OVERLAP the
|
|||
|
|
query window, matching the canonical web-route filter in
|
|||
|
|
routes/calendar_routes.py (`dtstart < end AND dtend > start`) and the
|
|||
|
|
recurring-expansion contract asserted in test_calendar_recurrence.py
|
|||
|
|
(test_expand_multi_day_crossing_range_start).
|
|||
|
|
|
|||
|
|
The buggy CLI filtered on `dtstart >= start AND dtstart < end`, which drops a
|
|||
|
|
multi-day / in-progress event that started before the window but is still
|
|||
|
|
running inside it (e.g. an all-day-running conference when you call
|
|||
|
|
`odysseus-calendar list` with the default start=now()).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import importlib.machinery
|
|||
|
|
import importlib.util
|
|||
|
|
import sys
|
|||
|
|
import types
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from unittest.mock import MagicMock
|
|||
|
|
|
|||
|
|
|
|||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _Col:
|
|||
|
|
"""A fake SQLAlchemy column that records comparison clauses instead of
|
|||
|
|
building SQL. `Col >= x` / `Col < x` / `Col > x` evaluate against a row
|
|||
|
|
later via .matches(row)."""
|
|||
|
|
|
|||
|
|
def __init__(self, name):
|
|||
|
|
self.name = name
|
|||
|
|
|
|||
|
|
def __ge__(self, other):
|
|||
|
|
return _Clause(self.name, ">=", other)
|
|||
|
|
|
|||
|
|
def __lt__(self, other):
|
|||
|
|
return _Clause(self.name, "<", other)
|
|||
|
|
|
|||
|
|
def __gt__(self, other):
|
|||
|
|
return _Clause(self.name, ">", other)
|
|||
|
|
|
|||
|
|
# asc()/order_by helpers used by cmd_list — return self, harmless.
|
|||
|
|
def asc(self):
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _Clause:
|
|||
|
|
def __init__(self, col, op, value):
|
|||
|
|
self.col = col
|
|||
|
|
self.op = op
|
|||
|
|
self.value = value
|
|||
|
|
|
|||
|
|
def matches(self, row):
|
|||
|
|
actual = getattr(row, self.col)
|
|||
|
|
if self.op == ">=":
|
|||
|
|
return actual >= self.value
|
|||
|
|
if self.op == "<":
|
|||
|
|
return actual < self.value
|
|||
|
|
if self.op == ">":
|
|||
|
|
return actual > self.value
|
|||
|
|
raise AssertionError(self.op)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _Query:
|
|||
|
|
def __init__(self, rows):
|
|||
|
|
self.rows = rows
|
|||
|
|
self.clauses = []
|
|||
|
|
|
|||
|
|
def filter(self, *conds):
|
|||
|
|
self.clauses.extend(conds)
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
def order_by(self, *a, **k):
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
def limit(self, n):
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
def first(self):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def all(self):
|
|||
|
|
out = []
|
|||
|
|
for r in self.rows:
|
|||
|
|
if all(c.matches(r) for c in self.clauses if isinstance(c, _Clause)):
|
|||
|
|
out.append(r)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_cli(monkeypatch, rows):
|
|||
|
|
db = types.ModuleType("core.database")
|
|||
|
|
session = MagicMock()
|
|||
|
|
session.query.return_value = _Query(rows)
|
|||
|
|
db.SessionLocal = MagicMock(return_value=session)
|
|||
|
|
cal_event = types.SimpleNamespace(dtstart=_Col("dtstart"), dtend=_Col("dtend"))
|
|||
|
|
db.CalendarEvent = cal_event
|
|||
|
|
db.CalendarCal = MagicMock()
|
|||
|
|
monkeypatch.setitem(sys.modules, "core.database", db)
|
|||
|
|
path = ROOT / "scripts" / "odysseus-calendar"
|
|||
|
|
loader = importlib.machinery.SourceFileLoader("odysseus_calendar_cli", str(path))
|
|||
|
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
|||
|
|
module = importlib.util.module_from_spec(spec)
|
|||
|
|
loader.exec_module(module)
|
|||
|
|
return module
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_list_includes_event_overlapping_window_start(monkeypatch, capsys):
|
|||
|
|
# Conference running 09:00–17:00; we list from 14:00 onward (default now()).
|
|||
|
|
ongoing = types.SimpleNamespace(
|
|||
|
|
dtstart=datetime(2026, 6, 3, 9, 0),
|
|||
|
|
dtend=datetime(2026, 6, 3, 17, 0),
|
|||
|
|
)
|
|||
|
|
cli = _load_cli(monkeypatch, [ongoing])
|
|||
|
|
|
|||
|
|
# Serialize to something trivial so emit() doesn't choke on the namespace.
|
|||
|
|
cli._serialize_event = lambda e: {"dtstart": e.dtstart.isoformat()}
|
|||
|
|
|
|||
|
|
args = types.SimpleNamespace(
|
|||
|
|
start="2026-06-03T14:00:00",
|
|||
|
|
end="2026-06-03T23:00:00",
|
|||
|
|
calendar=None,
|
|||
|
|
limit=100,
|
|||
|
|
pretty=False,
|
|||
|
|
)
|
|||
|
|
cli.cmd_list(args)
|
|||
|
|
out = capsys.readouterr().out
|
|||
|
|
assert "2026-06-03T09:00:00" in out, (
|
|||
|
|
"An event that started before the window but is still running inside "
|
|||
|
|
"it must be listed (overlap semantics), but it was dropped."
|
|||
|
|
)
|