feat(core): rule engine for alerts/agent actions and virtual demo clock
CI / ruff · pytest (pull_request) Successful in 18s
CI / ruff · pytest (pull_request) Successful in 18s
- app/core/clock.py: now()/today(), virtual offset (advance/set_now/reset), pluggable offset persistence hook (ADR-005) - config/rules.yaml + app/core/rules.py: YAML declares rules (params, actions, levels, reason_ru), Python evaluators implement predicates by id; dedup of open alerts, crisis never suppressed; ADR-006 colours (ADR-007) - tests: per-rule unit tests, dedup, clock, 13-day demo persona trajectory Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.core import clock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_clock(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
|
||||
clock.register_persistence(None, None)
|
||||
clock.reset()
|
||||
yield
|
||||
clock.register_persistence(None, None)
|
||||
clock.reset()
|
||||
|
||||
|
||||
def _close(a: datetime, b: datetime) -> bool:
|
||||
return abs(a - b) < timedelta(seconds=5)
|
||||
|
||||
|
||||
def test_now_is_tz_aware_in_settings_tz() -> None:
|
||||
now = clock.now()
|
||||
assert now.tzinfo is not None
|
||||
assert now.utcoffset() == timedelta(hours=3) # Europe/Moscow
|
||||
|
||||
|
||||
def test_advance_shifts_time() -> None:
|
||||
before = clock.now()
|
||||
after = clock.advance(days=1, hours=2, minutes=30)
|
||||
assert _close(after, before + timedelta(days=1, hours=2, minutes=30))
|
||||
assert clock.offset() == timedelta(days=1, hours=2, minutes=30)
|
||||
assert clock.today() == clock.now().date()
|
||||
|
||||
|
||||
def test_set_now_and_reset() -> None:
|
||||
target = datetime(2026, 10, 20, 9, 0)
|
||||
assert _close(clock.set_now(target), target.replace(tzinfo=clock.now().tzinfo))
|
||||
clock.reset()
|
||||
assert clock.offset() == timedelta(0)
|
||||
assert _close(clock.now(), clock._real_now())
|
||||
|
||||
|
||||
def test_persistence_hook_loads_and_saves() -> None:
|
||||
saved: list[timedelta] = []
|
||||
clock.register_persistence(lambda: timedelta(days=5), saved.append)
|
||||
assert clock.offset() == timedelta(days=5)
|
||||
clock.advance(days=1)
|
||||
assert saved[-1] == timedelta(days=6)
|
||||
clock.reset()
|
||||
assert saved[-1] == timedelta(0)
|
||||
|
||||
|
||||
def test_persistence_load_none_keeps_offset() -> None:
|
||||
clock.advance(hours=3)
|
||||
clock.register_persistence(lambda: None, None)
|
||||
assert clock.offset() == timedelta(hours=3)
|
||||
|
||||
|
||||
def test_real_mode_ignores_offset_and_forbids_moving(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clock.advance(days=3)
|
||||
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=False))
|
||||
assert clock.offset() == timedelta(0)
|
||||
assert _close(clock.now(), clock._real_now())
|
||||
with pytest.raises(RuntimeError):
|
||||
clock.advance(days=1)
|
||||
with pytest.raises(RuntimeError):
|
||||
clock.set_now(datetime(2026, 1, 1))
|
||||
Reference in New Issue
Block a user