Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""Tests for iai_mcp.quiet_window -- Task 2.
|
|
|
|
Covers 8 behaviours:
|
|
1. Western 9-5 user -> quiet window in 22:00-06:00 range.
|
|
2. Nocturnal autistic user -> quiet window in 14:00-20:00 range.
|
|
3. Shift worker rotating weekly -> returns some valid tuple OR None, no crash.
|
|
4. New user (<7d data) -> returns None; caller bootstraps.
|
|
5. 24/7 user with no quiet span -> returns None.
|
|
6. DST transition -> does not crash; returns tuple or None.
|
|
7. should_relearn 24h cadence.
|
|
8. should_bootstrap_trigger 2h-idle.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import uuid4
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
|
|
def _fresh_store(tmp_path, monkeypatch):
|
|
"""Isolated MemoryStore under tmp_path via IAI_MCP_STORE env override."""
|
|
monkeypatch.setenv("IAI_MCP_STORE", str(tmp_path / "iai"))
|
|
monkeypatch.setenv("IAI_MCP_EMBED_DIM", "384") # light schema, no real embeds
|
|
from iai_mcp.store import MemoryStore
|
|
return MemoryStore()
|
|
|
|
|
|
def _seed_sessions(
|
|
store,
|
|
*,
|
|
local_tz: ZoneInfo,
|
|
day_start_local: datetime,
|
|
hours: list[float],
|
|
days: int = 7,
|
|
sessions_per_hour: int = 3,
|
|
) -> None:
|
|
"""Emit synthetic `session_started` events at the given local-time hours
|
|
across `days` consecutive days, `sessions_per_hour` per hour.
|
|
"""
|
|
from iai_mcp.events import write_event
|
|
for d in range(days):
|
|
for h in hours:
|
|
for s in range(sessions_per_hour):
|
|
# local time -> UTC
|
|
local_dt = day_start_local + timedelta(days=d, hours=h, minutes=5 * s)
|
|
if local_dt.tzinfo is None:
|
|
local_dt = local_dt.replace(tzinfo=local_tz)
|
|
utc_dt = local_dt.astimezone(timezone.utc)
|
|
# Patch write_event's automatic ts by using raw table add:
|
|
# write_event uses datetime.now(timezone.utc), so we cannot
|
|
# control ts directly. Instead, directly insert into the
|
|
# events table with the synthetic ts.
|
|
_insert_event_direct(store, kind="session_started", ts=utc_dt, data={"n": s})
|
|
|
|
|
|
def _insert_event_direct(store, *, kind: str, ts: datetime, data: dict) -> None:
|
|
"""Bypass write_event so we can control `ts` deterministically."""
|
|
import json
|
|
from uuid import uuid4
|
|
|
|
from iai_mcp.crypto import encrypt_field
|
|
from iai_mcp.store import EVENTS_TABLE
|
|
|
|
event_id = str(uuid4())
|
|
data_plain = json.dumps(data)
|
|
ad = event_id.encode("ascii")
|
|
# store._key() lazy-loads the encryption key.
|
|
data_ct = encrypt_field(data_plain, store._key(), associated_data=ad)
|
|
row = {
|
|
"id": event_id,
|
|
"kind": kind,
|
|
"severity": "",
|
|
"domain": "",
|
|
"ts": ts,
|
|
"data_json": data_ct,
|
|
"session_id": "-",
|
|
"source_ids_json": json.dumps([]),
|
|
}
|
|
store.db.open_table(EVENTS_TABLE).add([row])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1: Western 9-5 -> quiet ~ 22:00-06:00
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_western_9_to_5_user(tmp_path, monkeypatch):
|
|
from iai_mcp.quiet_window import (
|
|
BUCKET_COUNT,
|
|
BUCKET_MINUTES,
|
|
learn_quiet_window,
|
|
)
|
|
|
|
tz = ZoneInfo("America/New_York")
|
|
store = _fresh_store(tmp_path, monkeypatch)
|
|
|
|
# 9-5 user: active 09:00-18:00 on 7 consecutive local-time days.
|
|
day_start = datetime(2026, 4, 1, 0, 0).replace(tzinfo=tz)
|
|
_seed_sessions(
|
|
store,
|
|
local_tz=tz,
|
|
day_start_local=day_start,
|
|
hours=[9, 10, 11, 12, 13, 14, 15, 16, 17],
|
|
days=7,
|
|
sessions_per_hour=3,
|
|
)
|
|
|
|
now = (day_start + timedelta(days=7, hours=8)).astimezone(timezone.utc)
|
|
result = learn_quiet_window(store, now, tz)
|
|
assert result is not None, "should detect quiet window for 9-5 user"
|
|
start_bucket, duration = result
|
|
|
|
# Start bucket should map to evening/night (17:30-02:00 local).
|
|
# Activity spans 09:00-17:10 (last event at 17:10), so the quiet window
|
|
# typically starts by 17:30. Accept any start in the 17:30-02:00 band.
|
|
start_hour = (start_bucket * BUCKET_MINUTES) // 60
|
|
start_minute = (start_bucket * BUCKET_MINUTES) % 60
|
|
in_evening = start_hour >= 17 and (start_hour > 17 or start_minute >= 30)
|
|
in_early_morning = start_hour <= 2
|
|
assert in_evening or in_early_morning, (
|
|
f"expected quiet start in 17:30-02:00 evening/night band, "
|
|
f"got {start_hour}:{start_minute:02d} (bucket={start_bucket})"
|
|
)
|
|
# Duration in 3-8h range.
|
|
assert 6 <= duration <= 16, f"duration out of range: {duration}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2: Nocturnal autistic -> quiet ~ 14:00-20:00
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_nocturnal_autistic_user(tmp_path, monkeypatch):
|
|
from iai_mcp.quiet_window import BUCKET_MINUTES, learn_quiet_window
|
|
|
|
tz = ZoneInfo("Europe/Moscow")
|
|
store = _fresh_store(tmp_path, monkeypatch)
|
|
|
|
# Nocturnal: active 22:00 through 04:00 (next day), sleeping during
|
|
# afternoon. Split around midnight: 22, 23 same day; 0, 1, 2, 3 next day.
|
|
day_start = datetime(2026, 4, 1, 0, 0).replace(tzinfo=tz)
|
|
_seed_sessions(
|
|
store,
|
|
local_tz=tz,
|
|
day_start_local=day_start,
|
|
hours=[22, 23, 24, 25, 26, 27, 28], # 22, 23, 0, 1, 2, 3, 4 local
|
|
days=7,
|
|
sessions_per_hour=3,
|
|
)
|
|
|
|
now = (day_start + timedelta(days=7, hours=12)).astimezone(timezone.utc)
|
|
result = learn_quiet_window(store, now, tz)
|
|
assert result is not None, "should detect quiet window for nocturnal user"
|
|
start_bucket, duration = result
|
|
start_hour = (start_bucket * BUCKET_MINUTES) // 60
|
|
# Expect quiet roughly in the daytime band (04:30-21:00): last activity ends
|
|
# around 04:10 local, so the first empty bucket is 04:30.
|
|
# The key invariant: NOT overlapping with the 22:00-04:00 active window.
|
|
assert 4 <= start_hour <= 21, (
|
|
f"nocturnal: expected quiet start in 04-21 band, got {start_hour}:00"
|
|
)
|
|
# And the window must not cover the 22:00-04:00 active region: check end
|
|
# bucket wraps back before 22:00 local.
|
|
end_bucket = (start_bucket + duration) % 48
|
|
end_hour = (end_bucket * BUCKET_MINUTES) // 60
|
|
assert end_hour <= 22, (
|
|
f"nocturnal window ends at {end_hour}:00, overlaps active 22-04 band"
|
|
)
|
|
assert 6 <= duration <= 16
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3: Shift worker (alternating day/night every 2 days)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_shift_worker_alternating(tmp_path, monkeypatch):
|
|
from iai_mcp.quiet_window import learn_quiet_window
|
|
|
|
tz = ZoneInfo("UTC")
|
|
store = _fresh_store(tmp_path, monkeypatch)
|
|
|
|
day_start = datetime(2026, 4, 1, 0, 0).replace(tzinfo=tz)
|
|
# Days 0, 1: day shift (active 08-16).
|
|
# Days 2, 3: night shift (active 20-04).
|
|
# Days 4, 5: day shift. Day 6: night shift.
|
|
for d in range(7):
|
|
if d in (0, 1, 4, 5):
|
|
hours = [8, 9, 10, 11, 12, 13, 14, 15]
|
|
else:
|
|
hours = [20, 21, 22, 23, 24, 25, 26, 27]
|
|
_seed_sessions(
|
|
store,
|
|
local_tz=tz,
|
|
day_start_local=day_start + timedelta(days=d),
|
|
hours=hours,
|
|
days=1,
|
|
sessions_per_hour=2,
|
|
)
|
|
|
|
now = (day_start + timedelta(days=7)).astimezone(timezone.utc)
|
|
# Must not crash; result is either a valid tuple or None.
|
|
result = learn_quiet_window(store, now, tz)
|
|
if result is not None:
|
|
start_bucket, duration = result
|
|
assert 0 <= start_bucket < 48
|
|
assert 6 <= duration <= 16
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4: New user (<7d) -> None (bootstrap)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_new_user_insufficient_days(tmp_path, monkeypatch):
|
|
from iai_mcp.quiet_window import learn_quiet_window
|
|
|
|
tz = ZoneInfo("UTC")
|
|
store = _fresh_store(tmp_path, monkeypatch)
|
|
|
|
day_start = datetime(2026, 4, 1, 0, 0).replace(tzinfo=tz)
|
|
_seed_sessions(
|
|
store,
|
|
local_tz=tz,
|
|
day_start_local=day_start,
|
|
hours=[9, 10, 11, 12, 13],
|
|
days=2, # < MIN_DAYS_FOR_LEARN
|
|
sessions_per_hour=3,
|
|
)
|
|
|
|
now = (day_start + timedelta(days=2, hours=14)).astimezone(timezone.utc)
|
|
result = learn_quiet_window(store, now, tz)
|
|
assert result is None, "should return None when <7d data"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 5: 24/7 user with no contiguous quiet window
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_24_7_user_no_quiet_span(tmp_path, monkeypatch):
|
|
from iai_mcp.quiet_window import learn_quiet_window
|
|
|
|
tz = ZoneInfo("UTC")
|
|
store = _fresh_store(tmp_path, monkeypatch)
|
|
|
|
day_start = datetime(2026, 4, 1, 0, 0).replace(tzinfo=tz)
|
|
# Active every hour of every day (no dip below threshold).
|
|
_seed_sessions(
|
|
store,
|
|
local_tz=tz,
|
|
day_start_local=day_start,
|
|
hours=list(range(24)),
|
|
days=7,
|
|
sessions_per_hour=3,
|
|
)
|
|
|
|
now = (day_start + timedelta(days=7)).astimezone(timezone.utc)
|
|
result = learn_quiet_window(store, now, tz)
|
|
# Completely uniform -> peak==every_bucket -> threshold=0.2*peak.
|
|
# All buckets equal -> none < threshold -> best_len=0 < min_buckets=6 -> None.
|
|
assert result is None, "24/7 uniform user should return None"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 6: DST spring-forward doesn't crash
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_dst_spring_forward_no_crash(tmp_path, monkeypatch):
|
|
from iai_mcp.quiet_window import learn_quiet_window
|
|
|
|
tz = ZoneInfo("America/New_York")
|
|
store = _fresh_store(tmp_path, monkeypatch)
|
|
|
|
# Seed 7 days that span DST start (US: 2026-03-08 at 02:00 jumps to 03:00).
|
|
day_start = datetime(2026, 3, 5, 0, 0).replace(tzinfo=tz)
|
|
_seed_sessions(
|
|
store,
|
|
local_tz=tz,
|
|
day_start_local=day_start,
|
|
hours=[9, 10, 12, 14, 17, 20],
|
|
days=7,
|
|
sessions_per_hour=2,
|
|
)
|
|
|
|
now = (day_start + timedelta(days=7)).astimezone(timezone.utc)
|
|
# Must not crash.
|
|
result = learn_quiet_window(store, now, tz)
|
|
if result is not None:
|
|
start_bucket, duration = result
|
|
assert 0 <= start_bucket < 48
|
|
assert 6 <= duration <= 16
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 7: should_relearn 24h cadence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_should_relearn_24h_cadence():
|
|
from iai_mcp.quiet_window import should_relearn
|
|
|
|
now = datetime(2026, 4, 18, 12, 0, tzinfo=timezone.utc)
|
|
# Never learned -> True.
|
|
assert should_relearn(None, now) is True
|
|
# 25h ago -> True.
|
|
assert should_relearn(now - timedelta(hours=25), now) is True
|
|
# Exactly 24h -> True (>= threshold).
|
|
assert should_relearn(now - timedelta(hours=24), now) is True
|
|
# 12h ago -> False.
|
|
assert should_relearn(now - timedelta(hours=12), now) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 8: should_bootstrap_trigger 2h-idle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_should_bootstrap_trigger_2h_idle():
|
|
from iai_mcp.quiet_window import should_bootstrap_trigger
|
|
|
|
now = datetime(2026, 4, 18, 12, 0, tzinfo=timezone.utc)
|
|
# No last session -> True (first-run idle).
|
|
assert should_bootstrap_trigger(None, now) is True
|
|
# 3h idle -> True.
|
|
assert should_bootstrap_trigger(now - timedelta(hours=3), now) is True
|
|
# 2h idle (== threshold) -> True.
|
|
assert should_bootstrap_trigger(now - timedelta(hours=2), now) is True
|
|
# 1h idle -> False.
|
|
assert should_bootstrap_trigger(now - timedelta(hours=1), now) is False
|