Lift the format-agnostic pieces from decnet/orchestrator/emailgen/
into the new decnet/realism/ library so file-class content generation
(stage 3 of the realism migration) can reuse them. Email-specific
delivery (RFC 2822 EML, IMAP/POP3 spool, thread chains) stays in
orchestrator/.
Renames (history-preserving git mv):
emailgen/personas.py -> realism/personas.py
emailgen/prompt.py -> realism/prompts/email.py
emailgen/global_pool.py -> realism/personas_pool.py
emailgen/llm/ -> realism/llm/
Env-var clean break (pre-v1, no aliases):
DECNET_EMAILGEN_LLM -> DECNET_REALISM_LLM
DECNET_EMAILGEN_MODEL -> DECNET_REALISM_MODEL
DECNET_EMAILGEN_TIMEOUT -> DECNET_REALISM_TIMEOUT
DECNET_EMAILGEN_PERSONAS -> DECNET_REALISM_PERSONAS
DECNET_EMAILGEN_FAKE_OUTPUT -> DECNET_REALISM_FAKE_OUTPUT
Importers rewritten in: orchestrator/emailgen/scheduler.py,
orchestrator/drivers/email.py, web/router/{emailgen,topology}/
api_personas.py, cli/emailgen.py. Tests for moved modules relocated
to tests/realism/; tests for stay-put modules updated in place.
API URL `/api/v1/emailgen/personas` and CLI `decnet emailgen
import-personas` keep their public names until the service-collapse
commit (stage 5).
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""Coverage for :mod:`decnet.realism.diurnal`.
|
|
|
|
Two functions to exercise:
|
|
|
|
* :func:`in_work_hours` — straightforward window membership including
|
|
the wrap-around (``22:00-06:00``) case and the fail-open behaviour
|
|
on malformed windows.
|
|
* :func:`sample_mtime` — must (a) return a ``datetime`` strictly in
|
|
the past, (b) clip to the configured backdate cap, and (c) snap the
|
|
hour-of-day into the persona's window when the unconstrained
|
|
candidate would land outside.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from decnet.realism.diurnal import in_work_hours, sample_mtime
|
|
|
|
|
|
# Fixed 'now' for reproducible tests — Monday 2026-04-27 14:00 UTC.
|
|
_NOW = datetime(2026, 4, 27, 14, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
# ---- in_work_hours -----------------------------------------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
"now_hour,now_min,window,expected",
|
|
[
|
|
(10, 0, "09:00-18:00", True),
|
|
(8, 59, "09:00-18:00", False),
|
|
(9, 0, "09:00-18:00", True),
|
|
(18, 0, "09:00-18:00", False), # exclusive end
|
|
(17, 59, "09:00-18:00", True),
|
|
(23, 30, "22:00-06:00", True), # wrap-around: late
|
|
(3, 0, "22:00-06:00", True), # wrap-around: early
|
|
(12, 0, "22:00-06:00", False), # wrap-around: middle of day
|
|
],
|
|
)
|
|
def test_in_work_hours_window_membership(
|
|
now_hour: int, now_min: int, window: str, expected: bool,
|
|
) -> None:
|
|
now = _NOW.replace(hour=now_hour, minute=now_min)
|
|
assert in_work_hours(window, now) is expected
|
|
|
|
|
|
def test_in_work_hours_equal_start_end_means_always_on() -> None:
|
|
# A persona pegged "00:00-00:00" should never be silenced by the
|
|
# diurnal gate — interpreted as "no schedule".
|
|
assert in_work_hours("00:00-00:00", _NOW) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"garbage",
|
|
["not-a-window", "9-18", "09:00", "25:00-26:00", "09:00-18:99", ""],
|
|
)
|
|
def test_malformed_window_fails_open(garbage: str) -> None:
|
|
# The fleet must not silence on a typo — same fail-open semantics
|
|
# as decnet.realism.personas.in_active_hours.
|
|
assert in_work_hours(garbage, _NOW) is True
|
|
|
|
|
|
# ---- sample_mtime ------------------------------------------------------
|
|
|
|
def test_sample_mtime_is_in_the_past() -> None:
|
|
rng = random.Random(0)
|
|
for _ in range(20):
|
|
mt = sample_mtime("09:00-18:00", _NOW, rand=rng)
|
|
assert mt < _NOW, f"sample_mtime returned future: {mt} >= {_NOW}"
|
|
|
|
|
|
def test_sample_mtime_respects_backdate_cap() -> None:
|
|
rng = random.Random(0)
|
|
cap_days = 7.0
|
|
for _ in range(50):
|
|
mt = sample_mtime(
|
|
"09:00-18:00", _NOW, rand=rng,
|
|
backdate_max_days=cap_days, backdate_min_hours=0.5,
|
|
)
|
|
assert _NOW - mt <= timedelta(days=cap_days) + timedelta(hours=1)
|
|
assert _NOW - mt >= timedelta(hours=0.5) - timedelta(seconds=1)
|
|
|
|
|
|
def test_sample_mtime_snaps_hour_into_window() -> None:
|
|
# Force a tight window then assert the hour-of-day is always in it.
|
|
rng = random.Random(42)
|
|
window = "09:00-18:00"
|
|
for _ in range(60):
|
|
mt = sample_mtime(window, _NOW, rand=rng)
|
|
assert 9 <= mt.hour < 18, (
|
|
f"hour {mt.hour} fell outside {window} on {mt.isoformat()}"
|
|
)
|
|
|
|
|
|
def test_sample_mtime_handles_wrap_around_window() -> None:
|
|
rng = random.Random(123)
|
|
for _ in range(40):
|
|
mt = sample_mtime("22:00-06:00", _NOW, rand=rng)
|
|
assert mt.hour >= 22 or mt.hour < 6, (
|
|
f"hour {mt.hour} fell outside wrap window on {mt.isoformat()}"
|
|
)
|
|
|
|
|
|
def test_sample_mtime_malformed_window_does_not_snap() -> None:
|
|
# When the window can't be parsed, just return the unconstrained
|
|
# backdate. Belt-and-braces: shouldn't crash, shouldn't future-stamp.
|
|
rng = random.Random(0)
|
|
mt = sample_mtime("garbage", _NOW, rand=rng)
|
|
assert mt < _NOW
|
|
|
|
|
|
def test_sample_mtime_is_deterministic_per_seed() -> None:
|
|
# The diurnal sampler accepts a Random — pinning the seed must
|
|
# produce stable output, otherwise tests can't assert anything
|
|
# tighter than "returns a datetime in the past."
|
|
a = sample_mtime("09:00-18:00", _NOW, rand=random.Random(7))
|
|
b = sample_mtime("09:00-18:00", _NOW, rand=random.Random(7))
|
|
assert a == b
|