merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/realism/__init__.py
Normal file
0
tests/realism/__init__.py
Normal file
68
tests/realism/test_bodies.py
Normal file
68
tests/realism/test_bodies.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Body templates produce realistic, non-empty output per content class."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism.bodies import make_body
|
||||
from decnet.realism.taxonomy import ContentClass
|
||||
|
||||
|
||||
_INERT_CLASSES = (
|
||||
ContentClass.NOTE,
|
||||
ContentClass.TODO,
|
||||
ContentClass.DRAFT,
|
||||
ContentClass.SCRIPT,
|
||||
ContentClass.LOG_CRON,
|
||||
ContentClass.LOG_DAEMON,
|
||||
ContentClass.CACHE_TMP,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", _INERT_CLASSES)
|
||||
def test_body_is_nonempty(cls: ContentClass) -> None:
|
||||
body = make_body(cls, "admin", rand=secrets.SystemRandom())
|
||||
assert isinstance(body, str)
|
||||
assert body.strip()
|
||||
|
||||
|
||||
def test_todo_body_uses_checkbox_markdown() -> None:
|
||||
body = make_body(ContentClass.TODO, "admin")
|
||||
# Each line should look like a markdown checkbox; we don't pin the
|
||||
# exact distribution because the % checked is randomised.
|
||||
for line in body.strip().splitlines():
|
||||
assert line.startswith("- [")
|
||||
|
||||
|
||||
def test_script_body_starts_with_shebang() -> None:
|
||||
seen_shebangs: set[str] = set()
|
||||
rng = secrets.SystemRandom()
|
||||
for _ in range(20):
|
||||
body = make_body(ContentClass.SCRIPT, "admin", rand=rng)
|
||||
assert body.startswith("#!")
|
||||
seen_shebangs.add(body.splitlines()[0])
|
||||
# We should pick from at least two interpreter shebangs across 20
|
||||
# trials; if not, the template list collapsed.
|
||||
assert len(seen_shebangs) >= 2
|
||||
|
||||
|
||||
def test_log_cron_body_has_cron_syslog_shape() -> None:
|
||||
body = make_body(ContentClass.LOG_CRON, "admin", rand=secrets.SystemRandom())
|
||||
for line in body.strip().splitlines():
|
||||
assert "CRON[" in line
|
||||
assert "CMD (" in line
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cls",
|
||||
[c for c in ContentClass if c.value.startswith("canary_")],
|
||||
)
|
||||
def test_canary_classes_raise_in_bodies(cls: ContentClass) -> None:
|
||||
with pytest.raises(NotImplementedError, match="canary"):
|
||||
make_body(cls, "admin")
|
||||
|
||||
|
||||
def test_email_class_raises_in_bodies() -> None:
|
||||
with pytest.raises(NotImplementedError, match="email"):
|
||||
make_body(ContentClass.EMAIL, "admin")
|
||||
128
tests/realism/test_bodies_llm.py
Normal file
128
tests/realism/test_bodies_llm.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""LLM-enriched body generation with deterministic fallback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism.bodies import make_body_with_llm
|
||||
from decnet.realism.llm.base import LLMResult, LLMTimeout
|
||||
from decnet.realism.llm.circuit import LLMCircuitBreaker
|
||||
from decnet.realism.personas import EmailPersona
|
||||
from decnet.realism.taxonomy import ContentClass
|
||||
|
||||
|
||||
def _persona(uses_llms: bool = False) -> EmailPersona:
|
||||
return EmailPersona(
|
||||
name="admin", email="admin@corp.com", role="ops",
|
||||
tone="direct", mannerisms=["uses bullets"],
|
||||
active_hours="00:00-00:00",
|
||||
uses_llms_heavily=uses_llms,
|
||||
)
|
||||
|
||||
|
||||
class _StubLLM:
|
||||
"""Async stub: returns canned LLMResult; no subprocess work."""
|
||||
|
||||
def __init__(self, *, text: str = "stub body\n", success: bool = True):
|
||||
self.model = "stub-model"
|
||||
self.timeout = 1.0
|
||||
self._result = LLMResult(
|
||||
success=success, text=text, model=self.model, latency_ms=1,
|
||||
)
|
||||
self.calls = 0
|
||||
|
||||
async def generate(self, prompt: str) -> LLMResult:
|
||||
self.calls += 1
|
||||
return self._result
|
||||
|
||||
|
||||
class _TimeoutLLM:
|
||||
model = "timeout-model"
|
||||
timeout = 0.05
|
||||
|
||||
async def generate(self, prompt: str) -> LLMResult:
|
||||
raise LLMTimeout("simulated")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_llm_falls_back_to_template() -> None:
|
||||
body = await make_body_with_llm(ContentClass.NOTE, _persona(), llm=None)
|
||||
assert body.strip() # template path returns non-empty
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_success_returns_llm_text() -> None:
|
||||
llm = _StubLLM(text="LLM-produced note body\n")
|
||||
body = await make_body_with_llm(
|
||||
ContentClass.NOTE, _persona(), llm=llm,
|
||||
)
|
||||
assert "LLM-produced note body" in body
|
||||
assert llm.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_em_dashes_are_stripped_for_default_persona() -> None:
|
||||
llm = _StubLLM(text="Hi — quick update — see attached.\n")
|
||||
body = await make_body_with_llm(
|
||||
ContentClass.NOTE, _persona(uses_llms=False), llm=llm,
|
||||
)
|
||||
assert "—" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_em_dashes_pass_through_for_llm_heavy_persona() -> None:
|
||||
llm = _StubLLM(text="Hi — quick update — see attached.\n")
|
||||
body = await make_body_with_llm(
|
||||
ContentClass.NOTE, _persona(uses_llms=True), llm=llm,
|
||||
)
|
||||
assert "—" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_falls_back_to_template_and_records_failure() -> None:
|
||||
breaker = LLMCircuitBreaker(failure_threshold=3, cooldown_seconds=10.0)
|
||||
body = await make_body_with_llm(
|
||||
ContentClass.NOTE, _persona(),
|
||||
llm=_TimeoutLLM(), breaker=breaker, timeout=0.01,
|
||||
)
|
||||
assert body.strip() # template fallback returned non-empty
|
||||
assert breaker.state == "closed" # one failure isn't enough to trip
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breaker_open_skips_llm_call() -> None:
|
||||
breaker = LLMCircuitBreaker(failure_threshold=1, cooldown_seconds=60.0)
|
||||
breaker.record_failure() # trip immediately
|
||||
assert breaker.allow_call() is False
|
||||
|
||||
llm = _StubLLM()
|
||||
body = await make_body_with_llm(
|
||||
ContentClass.NOTE, _persona(),
|
||||
llm=llm, breaker=breaker,
|
||||
)
|
||||
# LLM was NOT called (breaker open) — fallback to template.
|
||||
assert llm.calls == 0
|
||||
assert body.strip()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_class_never_invokes_llm() -> None:
|
||||
llm = _StubLLM()
|
||||
body = await make_body_with_llm(
|
||||
ContentClass.LOG_CRON, _persona(), llm=llm,
|
||||
)
|
||||
# System-class content is supposed to look formulaic; LLM-authored
|
||||
# cron logs would be a regression in realism.
|
||||
assert llm.calls == 0
|
||||
assert "CRON[" in body # template path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_llm_response_falls_back() -> None:
|
||||
llm = _StubLLM(text="", success=True)
|
||||
body = await make_body_with_llm(
|
||||
ContentClass.NOTE, _persona(), llm=llm,
|
||||
)
|
||||
# LLM ran but produced empty output → template fallback.
|
||||
assert body.strip()
|
||||
81
tests/realism/test_circuit_breaker.py
Normal file
81
tests/realism/test_circuit_breaker.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""LLMCircuitBreaker — process-local sliding-window breaker."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.realism.llm.circuit import LLMCircuitBreaker
|
||||
|
||||
|
||||
def test_starts_closed_and_allows_calls() -> None:
|
||||
breaker = LLMCircuitBreaker()
|
||||
assert breaker.state == "closed"
|
||||
assert breaker.allow_call() is True
|
||||
|
||||
|
||||
def test_trips_open_after_threshold_failures() -> None:
|
||||
clock_value = [0.0]
|
||||
breaker = LLMCircuitBreaker(
|
||||
failure_threshold=3, cooldown_seconds=60.0,
|
||||
clock=lambda: clock_value[0],
|
||||
)
|
||||
breaker.record_failure()
|
||||
assert breaker.state == "closed"
|
||||
breaker.record_failure()
|
||||
assert breaker.state == "closed"
|
||||
breaker.record_failure()
|
||||
assert breaker.state == "open"
|
||||
assert breaker.allow_call() is False
|
||||
|
||||
|
||||
def test_success_resets_consecutive_failure_count() -> None:
|
||||
breaker = LLMCircuitBreaker(failure_threshold=3)
|
||||
breaker.record_failure()
|
||||
breaker.record_failure()
|
||||
breaker.record_success()
|
||||
breaker.record_failure()
|
||||
breaker.record_failure()
|
||||
assert breaker.state == "closed" # only 2 since the success
|
||||
|
||||
|
||||
def test_half_open_after_cooldown() -> None:
|
||||
clock_value = [0.0]
|
||||
breaker = LLMCircuitBreaker(
|
||||
failure_threshold=2, cooldown_seconds=10.0,
|
||||
clock=lambda: clock_value[0],
|
||||
)
|
||||
breaker.record_failure()
|
||||
breaker.record_failure()
|
||||
assert breaker.state == "open"
|
||||
assert breaker.allow_call() is False
|
||||
|
||||
clock_value[0] = 11.0
|
||||
assert breaker.allow_call() is True
|
||||
assert breaker.state == "half_open"
|
||||
|
||||
|
||||
def test_half_open_failure_re_opens() -> None:
|
||||
clock_value = [0.0]
|
||||
breaker = LLMCircuitBreaker(
|
||||
failure_threshold=2, cooldown_seconds=5.0,
|
||||
clock=lambda: clock_value[0],
|
||||
)
|
||||
breaker.record_failure()
|
||||
breaker.record_failure()
|
||||
clock_value[0] = 6.0
|
||||
breaker.allow_call()
|
||||
assert breaker.state == "half_open"
|
||||
breaker.record_failure()
|
||||
assert breaker.state == "open"
|
||||
|
||||
|
||||
def test_half_open_success_closes() -> None:
|
||||
clock_value = [0.0]
|
||||
breaker = LLMCircuitBreaker(
|
||||
failure_threshold=2, cooldown_seconds=5.0,
|
||||
clock=lambda: clock_value[0],
|
||||
)
|
||||
breaker.record_failure()
|
||||
breaker.record_failure()
|
||||
clock_value[0] = 6.0
|
||||
breaker.allow_call()
|
||||
breaker.record_success()
|
||||
assert breaker.state == "closed"
|
||||
assert breaker.allow_call() is True
|
||||
120
tests/realism/test_diurnal.py
Normal file
120
tests/realism/test_diurnal.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""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
|
||||
98
tests/realism/test_edit.py
Normal file
98
tests/realism/test_edit.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""next_iteration mutators per content class.
|
||||
|
||||
Stage 3b — read-modify-write contract: each editor takes a previous
|
||||
body and returns a plausible next iteration. Append-only for logs;
|
||||
small in-place edits for user content.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism.bodies import next_iteration
|
||||
from decnet.realism.taxonomy import ContentClass
|
||||
|
||||
|
||||
def test_todo_edit_can_flip_an_unchecked_box() -> None:
|
||||
prev = "- [ ] rotate keys\n- [ ] review pr\n"
|
||||
seen_flip = False
|
||||
for seed in range(40):
|
||||
new = next_iteration(
|
||||
ContentClass.TODO, "admin", prev, rand=random.Random(seed),
|
||||
)
|
||||
if "[x]" in new and "rotate" in new and "[x] rotate" in new:
|
||||
seen_flip = True
|
||||
if "[x]" in new and "[x] review" in new:
|
||||
seen_flip = True
|
||||
if seen_flip:
|
||||
break
|
||||
assert seen_flip, "no checkbox flip across 40 seeds — mutator broken"
|
||||
|
||||
|
||||
def test_todo_edit_grows_or_holds_line_count() -> None:
|
||||
prev = "- [ ] rotate keys\n"
|
||||
new = next_iteration(
|
||||
ContentClass.TODO, "admin", prev, rand=random.Random(0),
|
||||
)
|
||||
# Mutators may flip a box (same line count) or append (more lines)
|
||||
# — but never shrink the file.
|
||||
assert len(new.splitlines()) >= len(prev.splitlines())
|
||||
|
||||
|
||||
def test_log_cron_edit_is_append_only() -> None:
|
||||
prev = (
|
||||
"Apr 27 09:00:01 hostname CRON[1234]: (root) CMD (run-parts /etc/cron.daily)\n"
|
||||
)
|
||||
new = next_iteration(
|
||||
ContentClass.LOG_CRON, "admin", prev, rand=random.Random(0),
|
||||
)
|
||||
assert new.startswith(prev.rstrip())
|
||||
assert len(new.splitlines()) > len(prev.splitlines())
|
||||
|
||||
|
||||
def test_log_daemon_edit_is_append_only() -> None:
|
||||
prev = "Apr 27 09:00:01 hostname systemd[1]: Started Daily apt download activities.\n"
|
||||
new = next_iteration(
|
||||
ContentClass.LOG_DAEMON, "admin", prev, rand=random.Random(0),
|
||||
)
|
||||
assert new.startswith(prev.rstrip())
|
||||
|
||||
|
||||
def test_note_edit_grows_the_body() -> None:
|
||||
prev = "remember to ping the on-call\n"
|
||||
new = next_iteration(
|
||||
ContentClass.NOTE, "admin", prev, rand=random.Random(0),
|
||||
)
|
||||
assert prev in new
|
||||
assert len(new) > len(prev)
|
||||
|
||||
|
||||
def test_draft_edit_appends_paragraph() -> None:
|
||||
prev = "Hi team,\n\nQuick update.\n"
|
||||
new = next_iteration(
|
||||
ContentClass.DRAFT, "admin", prev, rand=random.Random(0),
|
||||
)
|
||||
assert new.startswith(prev.rstrip())
|
||||
assert len(new) > len(prev)
|
||||
|
||||
|
||||
def test_script_edit_appends_comment() -> None:
|
||||
prev = "#!/usr/bin/env bash\nset -e\necho 'hi'\n"
|
||||
new = next_iteration(
|
||||
ContentClass.SCRIPT, "admin", prev, rand=random.Random(0),
|
||||
)
|
||||
assert new.startswith(prev.rstrip())
|
||||
# New tail must be a comment (the editor's contract); never a
|
||||
# silently-injected new exec line.
|
||||
new_tail = new[len(prev.rstrip()):].strip()
|
||||
assert new_tail.startswith("#")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", [
|
||||
ContentClass.CACHE_TMP, ContentClass.EMAIL,
|
||||
ContentClass.CANARY_AWS_CREDS, ContentClass.CANARY_HONEYDOC,
|
||||
])
|
||||
def test_unsupported_classes_raise_in_edit(cls: ContentClass) -> None:
|
||||
with pytest.raises(KeyError):
|
||||
next_iteration(cls, "admin", "anything")
|
||||
152
tests/realism/test_email_prompt.py
Normal file
152
tests/realism/test_email_prompt.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Prompt builder behaviour: language constraint, em-dash suppression,
|
||||
deterministic mannerism injection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from decnet.realism.personas import EmailPersona
|
||||
from decnet.realism.prompts.email import (
|
||||
PromptInputs,
|
||||
build,
|
||||
select_mannerisms,
|
||||
)
|
||||
|
||||
|
||||
def _persona(**over) -> EmailPersona:
|
||||
base = dict(
|
||||
name="John Smith",
|
||||
email="john@corp.com",
|
||||
role="COO",
|
||||
tone="formal",
|
||||
mannerisms=[
|
||||
"opens with 'I hope this finds you well'",
|
||||
"uses 'Best regards' exclusively",
|
||||
"references policy by number",
|
||||
"ccs legal",
|
||||
],
|
||||
language="en",
|
||||
)
|
||||
base.update(over)
|
||||
return EmailPersona(**base)
|
||||
|
||||
|
||||
class _SeededRng:
|
||||
"""Adapter so prompt code thinks it has a SystemRandom."""
|
||||
|
||||
def __init__(self, seed: int):
|
||||
self._r = random.Random(seed)
|
||||
|
||||
def shuffle(self, seq):
|
||||
self._r.shuffle(seq)
|
||||
|
||||
def random(self):
|
||||
return self._r.random()
|
||||
|
||||
def choice(self, seq):
|
||||
return self._r.choice(seq)
|
||||
|
||||
|
||||
def test_select_mannerisms_returns_subset_of_pool():
|
||||
persona = _persona()
|
||||
picks = select_mannerisms(persona, rng=_SeededRng(0), n=2)
|
||||
assert len(picks) == 2
|
||||
assert all(m in persona.mannerisms for m in picks)
|
||||
|
||||
|
||||
def test_select_mannerisms_deterministic_under_same_seed():
|
||||
persona = _persona()
|
||||
a = select_mannerisms(persona, rng=_SeededRng(42), n=2)
|
||||
b = select_mannerisms(persona, rng=_SeededRng(42), n=2)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_select_mannerisms_returns_all_when_pool_smaller_than_n():
|
||||
persona = _persona(mannerisms=["a"])
|
||||
picks = select_mannerisms(persona, rng=_SeededRng(0), n=2)
|
||||
assert picks == ["a"]
|
||||
|
||||
|
||||
def test_select_mannerisms_empty_pool():
|
||||
persona = _persona(mannerisms=[])
|
||||
assert select_mannerisms(persona) == []
|
||||
|
||||
|
||||
def test_build_includes_language_constraint_english():
|
||||
sender = _persona(language="en")
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "in English" in prompt
|
||||
|
||||
|
||||
def test_build_includes_language_constraint_spanish():
|
||||
sender = _persona(language="es")
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "in Spanish" in prompt
|
||||
|
||||
|
||||
def test_build_em_dash_suppression_default():
|
||||
sender = _persona()
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "Do NOT use em-dashes" in prompt
|
||||
|
||||
|
||||
def test_build_em_dash_lifted_for_llm_heavy_persona():
|
||||
sender = _persona(uses_llms_heavily=True)
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "Do NOT use em-dashes" not in prompt
|
||||
assert "fine" in prompt.lower()
|
||||
|
||||
|
||||
def test_build_reply_thread_block_prefixes_re():
|
||||
sender = _persona()
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(
|
||||
sender=sender,
|
||||
recipient=recip,
|
||||
context_hint="budget",
|
||||
parent_subject="Re: Q3 budget",
|
||||
parent_excerpt="Numbers attached.",
|
||||
),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "REPLY in an ongoing thread" in prompt
|
||||
assert "Re: Q3 budget" in prompt
|
||||
assert "Numbers attached" in prompt
|
||||
assert "prefixed with 'Re: '" in prompt
|
||||
|
||||
|
||||
def test_build_returns_mannerisms_used_metadata():
|
||||
sender = _persona()
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
_, used = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(7),
|
||||
)
|
||||
assert used
|
||||
assert all(m in sender.mannerisms for m in used)
|
||||
|
||||
|
||||
def test_build_uses_explicit_signature_when_provided():
|
||||
sender = _persona(signature="-- John\\nCOO")
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "Use this exact signature block" in prompt
|
||||
137
tests/realism/test_llm.py
Normal file
137
tests/realism/test_llm.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""LLM backend factory + Ollama implementation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism.llm import LLMTimeout, get_llm
|
||||
from decnet.realism.llm.impl.fake import FakeBackend
|
||||
from decnet.realism.llm.impl.ollama import OllamaBackend
|
||||
|
||||
|
||||
# ── factory dispatch ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_factory_default_is_ollama(monkeypatch):
|
||||
monkeypatch.delenv("DECNET_REALISM_LLM", raising=False)
|
||||
backend = get_llm()
|
||||
assert isinstance(backend, OllamaBackend)
|
||||
|
||||
|
||||
def test_factory_selects_fake(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_REALISM_LLM", "fake")
|
||||
backend = get_llm()
|
||||
assert isinstance(backend, FakeBackend)
|
||||
|
||||
|
||||
def test_factory_unknown_raises(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_REALISM_LLM", "vllm-someday")
|
||||
with pytest.raises(ValueError, match="Unsupported"):
|
||||
get_llm()
|
||||
|
||||
|
||||
def test_factory_passes_model_through(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_REALISM_LLM", "ollama")
|
||||
backend = get_llm(model="qwen2:7b")
|
||||
assert backend.model == "qwen2:7b"
|
||||
|
||||
|
||||
# ── FakeBackend ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_backend_returns_canned_output():
|
||||
fb = FakeBackend(output="Subject: hi\n\nbody")
|
||||
result = await fb.generate("any prompt")
|
||||
assert result.success is True
|
||||
assert result.text.startswith("Subject:")
|
||||
assert result.model == "fake-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_backend_can_simulate_failure():
|
||||
fb = FakeBackend(success=False)
|
||||
result = await fb.generate("prompt")
|
||||
assert result.success is False
|
||||
assert result.text == ""
|
||||
|
||||
|
||||
# ── OllamaBackend (subprocess stubbed) ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_backend_success(monkeypatch):
|
||||
"""Stub asyncio.create_subprocess_exec to return canned stdout."""
|
||||
|
||||
class _StubProc:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self, _stdin):
|
||||
return b"Subject: hi\n\nbody\n", b""
|
||||
|
||||
async def fake_create(*args, **kwargs): # noqa: ARG001
|
||||
return _StubProc()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create)
|
||||
|
||||
backend = OllamaBackend(model="m1", timeout=1.0)
|
||||
result = await backend.generate("hello")
|
||||
assert result.success is True
|
||||
assert "Subject:" in result.text
|
||||
assert result.model == "m1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_backend_non_zero_rc_marks_failure(monkeypatch):
|
||||
class _StubProc:
|
||||
returncode = 1
|
||||
|
||||
async def communicate(self, _stdin):
|
||||
return b"", b"model not found"
|
||||
|
||||
async def fake_create(*args, **kwargs): # noqa: ARG001
|
||||
return _StubProc()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create)
|
||||
|
||||
backend = OllamaBackend(model="m1", timeout=1.0)
|
||||
result = await backend.generate("hello")
|
||||
assert result.success is False
|
||||
assert result.extra["rc"] == 1
|
||||
assert "model not found" in result.extra["stderr"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_backend_timeout_raises(monkeypatch):
|
||||
class _StubProc:
|
||||
returncode = None
|
||||
|
||||
async def communicate(self, _stdin):
|
||||
await asyncio.sleep(10) # well past the timeout
|
||||
return b"", b""
|
||||
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
async def fake_create(*args, **kwargs): # noqa: ARG001
|
||||
return _StubProc()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create)
|
||||
|
||||
backend = OllamaBackend(model="m1", timeout=0.05)
|
||||
with pytest.raises(LLMTimeout):
|
||||
await backend.generate("hello")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_backend_missing_binary_returns_failure(monkeypatch):
|
||||
async def fake_create(*args, **kwargs): # noqa: ARG001
|
||||
raise FileNotFoundError("ollama: not found")
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create)
|
||||
|
||||
backend = OllamaBackend(model="m1", timeout=1.0)
|
||||
result = await backend.generate("hello")
|
||||
assert result.success is False
|
||||
assert result.extra["rc"] == 127
|
||||
95
tests/realism/test_naming.py
Normal file
95
tests/realism/test_naming.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Filename realism contracts.
|
||||
|
||||
The pre-realism orchestrator emitted ``notes-1777315854.txt`` —
|
||||
unix-epoch suffix, instant tell. This file pins the anti-regression:
|
||||
no namer is allowed to drop a raw decimal timestamp into a filename.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism.naming import make_path
|
||||
from decnet.realism.taxonomy import ContentClass
|
||||
|
||||
|
||||
_USER_CLASSES = (
|
||||
ContentClass.NOTE,
|
||||
ContentClass.TODO,
|
||||
ContentClass.DRAFT,
|
||||
ContentClass.SCRIPT,
|
||||
)
|
||||
_SYSTEM_CLASSES = (
|
||||
ContentClass.LOG_CRON,
|
||||
ContentClass.LOG_DAEMON,
|
||||
ContentClass.CACHE_TMP,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", _USER_CLASSES)
|
||||
def test_user_class_paths_live_under_persona_home(cls: ContentClass) -> None:
|
||||
p = make_path(cls, "admin", rand=secrets.SystemRandom())
|
||||
assert p.startswith("/home/admin/"), p
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", _SYSTEM_CLASSES)
|
||||
def test_system_class_paths_have_no_epoch_suffix(cls: ContentClass) -> None:
|
||||
rng = secrets.SystemRandom()
|
||||
for _ in range(20):
|
||||
p = make_path(cls, "admin", rand=rng)
|
||||
# The realism failure today: filenames carry raw unix epochs.
|
||||
# 8+ consecutive digits in the basename is the tell.
|
||||
basename = p.rsplit("/", 1)[-1]
|
||||
assert not re.search(r"\d{8,}", basename), (
|
||||
f"epoch-shaped suffix found in {p!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_log_cron_uses_logrotate_skeleton() -> None:
|
||||
seen: set[str] = set()
|
||||
rng = secrets.SystemRandom()
|
||||
for _ in range(40):
|
||||
seen.add(make_path(ContentClass.LOG_CRON, "admin", rand=rng))
|
||||
# Real cron only ever writes a fixed set of names; anything outside
|
||||
# the logrotate cycle is a realism bug.
|
||||
expected = {"/var/log/cron.log", "/var/log/cron.log.1", "/var/log/cron.log.2.gz"}
|
||||
assert seen <= expected
|
||||
# And we should see at least the canonical name across 40 trials.
|
||||
assert "/var/log/cron.log" in seen
|
||||
|
||||
|
||||
def test_cache_tmp_uses_mkstemp_shape() -> None:
|
||||
p = make_path(ContentClass.CACHE_TMP, "admin")
|
||||
assert re.match(r"^/tmp/\.cache-[a-z0-9]{6}$", p), p
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cls",
|
||||
[c for c in ContentClass if c.value.startswith("canary_")],
|
||||
)
|
||||
def test_canary_classes_raise_in_naming(cls: ContentClass) -> None:
|
||||
with pytest.raises(NotImplementedError, match="canary"):
|
||||
make_path(cls, "admin")
|
||||
|
||||
|
||||
def test_email_class_raises_in_naming() -> None:
|
||||
with pytest.raises(NotImplementedError, match="email"):
|
||||
make_path(ContentClass.EMAIL, "admin")
|
||||
|
||||
|
||||
def test_persona_with_spaces_normalises_to_login() -> None:
|
||||
# "John Smith" → "johnsmith" is a plausible login, so the namer
|
||||
# collapses spaces rather than falling back. This pins that
|
||||
# behaviour against a future overcorrection.
|
||||
p = make_path(ContentClass.NOTE, "John Smith")
|
||||
assert p.startswith("/home/johnsmith/")
|
||||
|
||||
|
||||
def test_persona_with_punctuation_falls_back_to_user_home() -> None:
|
||||
# A persona name with punctuation (or non-ASCII letters) can't
|
||||
# cleanly become a username; the namer must fall back to
|
||||
# /home/user rather than leak weird chars onto the filesystem.
|
||||
p = make_path(ContentClass.NOTE, "C-3PO!")
|
||||
assert p.startswith("/home/user/")
|
||||
127
tests/realism/test_personas.py
Normal file
127
tests/realism/test_personas.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Persona schema parsing + active-hours window tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from decnet.realism.personas import (
|
||||
EmailPersona,
|
||||
in_active_hours,
|
||||
login_for,
|
||||
parse_personas,
|
||||
)
|
||||
|
||||
|
||||
def _persona(**over) -> dict:
|
||||
base = {
|
||||
"name": "John Smith",
|
||||
"email": "john@corp.com",
|
||||
"role": "COO",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses 'Best regards'"],
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_parse_empty_inputs():
|
||||
assert parse_personas(None) == []
|
||||
assert parse_personas("") == []
|
||||
assert parse_personas([]) == []
|
||||
|
||||
|
||||
def test_parse_invalid_json_returns_empty_no_raise():
|
||||
assert parse_personas("{not json") == []
|
||||
|
||||
|
||||
def test_parse_invalid_top_level_shape_returns_empty():
|
||||
assert parse_personas('{"not": "a list"}') == []
|
||||
|
||||
|
||||
def test_parse_drops_invalid_entry_keeps_valid():
|
||||
raw = json.dumps([
|
||||
_persona(),
|
||||
{"name": "broken", "email": "not-an-email"},
|
||||
_persona(name="Sarah", email="sarah@corp.com"),
|
||||
])
|
||||
parsed = parse_personas(raw)
|
||||
assert len(parsed) == 2
|
||||
assert {p.name for p in parsed} == {"John Smith", "Sarah"}
|
||||
|
||||
|
||||
def test_parse_resolves_language_default_when_unset():
|
||||
raw = json.dumps([_persona()])
|
||||
parsed = parse_personas(raw, language_default="es")
|
||||
assert parsed[0].language == "es"
|
||||
|
||||
|
||||
def test_parse_persona_language_overrides_default():
|
||||
raw = json.dumps([_persona(language="pt")])
|
||||
parsed = parse_personas(raw, language_default="es")
|
||||
assert parsed[0].language == "pt"
|
||||
|
||||
|
||||
def test_parse_accepts_python_list_directly():
|
||||
parsed = parse_personas([_persona()])
|
||||
assert len(parsed) == 1
|
||||
|
||||
|
||||
def test_uses_llms_heavily_default_false():
|
||||
parsed = parse_personas([_persona()])
|
||||
assert parsed[0].uses_llms_heavily is False
|
||||
|
||||
|
||||
def test_uses_llms_heavily_can_be_set():
|
||||
parsed = parse_personas([_persona(uses_llms_heavily=True)])
|
||||
assert parsed[0].uses_llms_heavily is True
|
||||
|
||||
|
||||
def test_active_hours_normal_window():
|
||||
p = EmailPersona(**_persona(active_hours="09:00-18:00"))
|
||||
assert in_active_hours(p, 12) is True
|
||||
assert in_active_hours(p, 8) is False
|
||||
assert in_active_hours(p, 18) is False
|
||||
assert in_active_hours(p, 9) is True
|
||||
|
||||
|
||||
def test_active_hours_wraparound_window():
|
||||
p = EmailPersona(**_persona(active_hours="22:00-06:00"))
|
||||
assert in_active_hours(p, 23) is True
|
||||
assert in_active_hours(p, 0) is True
|
||||
assert in_active_hours(p, 5) is True
|
||||
assert in_active_hours(p, 7) is False
|
||||
|
||||
|
||||
def test_active_hours_malformed_treats_as_always_on():
|
||||
p = EmailPersona(**_persona(active_hours="garbage"))
|
||||
assert in_active_hours(p, 0) is True
|
||||
assert in_active_hours(p, 23) is True
|
||||
|
||||
|
||||
def test_active_hours_equal_window_treated_as_always_on():
|
||||
p = EmailPersona(**_persona(active_hours="10:00-10:00"))
|
||||
assert in_active_hours(p, 5) is True
|
||||
|
||||
|
||||
def test_login_for_normalises_display_name():
|
||||
assert login_for("John Smith") == "johnsmith"
|
||||
assert login_for("alice") == "alice"
|
||||
|
||||
|
||||
def test_login_for_falls_back_to_user_on_punctuation():
|
||||
# The realism namer and canary cultivator both rely on this so the
|
||||
# decky filesystem doesn't end up with an unexpected username.
|
||||
assert login_for("Mr. Robot") == "user"
|
||||
assert login_for("") == "user"
|
||||
assert login_for("Renée") == "user" # non-ASCII falls back
|
||||
|
||||
|
||||
def test_login_for_shared_by_naming_and_cultivator():
|
||||
"""Single source of truth: realism naming and canary cultivator
|
||||
must agree on the persona→login mapping."""
|
||||
from decnet.canary import cultivator
|
||||
from decnet.realism import naming
|
||||
persona = "John Smith"
|
||||
expected = login_for(persona)
|
||||
assert naming._home(persona) == f"/home/{expected}"
|
||||
# cultivator imports login_for; not duplicated.
|
||||
assert cultivator.login_for is login_for
|
||||
99
tests/realism/test_personas_pool.py
Normal file
99
tests/realism/test_personas_pool.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Global persona pool — disk-backed source for fleet/shard mail deckies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism import personas_pool as global_pool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset():
|
||||
global_pool.reset_cache()
|
||||
yield
|
||||
global_pool.reset_cache()
|
||||
|
||||
|
||||
_TWO = [
|
||||
{
|
||||
"name": "John Smith",
|
||||
"email": "john@corp.com",
|
||||
"role": "COO",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses 'Best regards'"],
|
||||
},
|
||||
{
|
||||
"name": "Sarah Johnson",
|
||||
"email": "sarah@corp.com",
|
||||
"role": "PM",
|
||||
"tone": "direct",
|
||||
"mannerisms": ["uses bullets"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_load_returns_empty_when_file_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"DECNET_REALISM_PERSONAS", str(tmp_path / "does-not-exist.json")
|
||||
)
|
||||
assert global_pool.load() == []
|
||||
|
||||
|
||||
def test_load_returns_parsed_personas(tmp_path, monkeypatch):
|
||||
f = tmp_path / "personas.json"
|
||||
f.write_text(json.dumps(_TWO))
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
|
||||
personas = global_pool.load()
|
||||
assert len(personas) == 2
|
||||
assert {p.email for p in personas} == {"john@corp.com", "sarah@corp.com"}
|
||||
|
||||
|
||||
def test_load_resolves_language_default(tmp_path, monkeypatch):
|
||||
f = tmp_path / "personas.json"
|
||||
f.write_text(json.dumps(_TWO))
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
|
||||
personas = global_pool.load(language_default="es")
|
||||
assert all(p.language == "es" for p in personas)
|
||||
|
||||
|
||||
def test_load_invalid_json_returns_empty(tmp_path, monkeypatch):
|
||||
f = tmp_path / "personas.json"
|
||||
f.write_text("{not valid")
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
|
||||
assert global_pool.load() == []
|
||||
|
||||
|
||||
def test_load_caches_until_mtime_changes(tmp_path, monkeypatch):
|
||||
f = tmp_path / "personas.json"
|
||||
f.write_text(json.dumps(_TWO))
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
|
||||
|
||||
first = global_pool.load()
|
||||
assert len(first) == 2
|
||||
|
||||
# Re-write with a single persona; bump mtime so the cache invalidates.
|
||||
import time as _time
|
||||
_time.sleep(0.01)
|
||||
f.write_text(json.dumps(_TWO[:1]))
|
||||
import os
|
||||
os.utime(f, None)
|
||||
|
||||
second = global_pool.load()
|
||||
assert len(second) == 1
|
||||
|
||||
|
||||
def test_resolve_path_honours_env_override(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(tmp_path / "x.json"))
|
||||
assert global_pool.resolve_path() == tmp_path / "x.json"
|
||||
|
||||
|
||||
def test_resolve_path_falls_back_to_user_path_when_system_missing(monkeypatch):
|
||||
monkeypatch.delenv("DECNET_REALISM_PERSONAS", raising=False)
|
||||
# In a typical dev box /etc/decnet/ doesn't exist; the resolver
|
||||
# should pick ~/.decnet/email_personas.json.
|
||||
p = global_pool.resolve_path()
|
||||
# We don't assert the exact path (depends on whether /etc/decnet
|
||||
# exists on the test host), only that it ends with the canonical
|
||||
# filename and isn't an empty path.
|
||||
assert p.name == "email_personas.json"
|
||||
114
tests/realism/test_planner.py
Normal file
114
tests/realism/test_planner.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Realism planner — picks (decky, persona, class, action, mtime).
|
||||
|
||||
Stage 3 ships create-only plans; the edit branch lands in 3b. Tests
|
||||
pin the diurnal gate, the eligibility filter, and the create
|
||||
contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism.personas import EmailPersona
|
||||
from decnet.realism.planner import pick
|
||||
from decnet.realism.taxonomy import ContentClass
|
||||
|
||||
|
||||
def _persona(name: str = "admin", window: str = "00:00-00:00") -> EmailPersona:
|
||||
return EmailPersona(
|
||||
name=name,
|
||||
email=f"{name}@corp.com",
|
||||
role="ops",
|
||||
tone="direct",
|
||||
active_hours=window,
|
||||
)
|
||||
|
||||
|
||||
def _decky(uuid: str = "u1", name: str = "decky-01", personas=None) -> dict:
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"_realism_personas": personas or [_persona()],
|
||||
}
|
||||
|
||||
|
||||
_NOW = datetime(2026, 4, 27, 14, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_pick_returns_none_when_no_deckies() -> None:
|
||||
assert pick([], _NOW) is None
|
||||
|
||||
|
||||
def test_pick_returns_none_when_decky_has_no_personas() -> None:
|
||||
assert pick([{"uuid": "u1", "name": "d", "_realism_personas": []}], _NOW) is None
|
||||
|
||||
|
||||
def test_pick_filters_personas_outside_window() -> None:
|
||||
# A persona pegged to 01:00-02:00 with now=14:00 must not be picked.
|
||||
out_of_hours = _persona(window="01:00-02:00")
|
||||
deckies = [_decky(personas=[out_of_hours])]
|
||||
assert pick(deckies, _NOW) is None
|
||||
|
||||
|
||||
def test_pick_returns_create_plan_with_mtime_in_past() -> None:
|
||||
deckies = [_decky()]
|
||||
plan = pick(deckies, _NOW, rand=random.Random(0))
|
||||
assert plan is not None
|
||||
assert plan.action == "create"
|
||||
assert plan.decky_uuid == "u1"
|
||||
assert plan.persona == "admin"
|
||||
assert plan.target_path.startswith("/")
|
||||
assert plan.body_hint
|
||||
assert plan.mtime < _NOW
|
||||
|
||||
|
||||
def test_pick_distributes_across_user_and_system_classes() -> None:
|
||||
deckies = [_decky()]
|
||||
seen: set[ContentClass] = set()
|
||||
for seed in range(80):
|
||||
plan = pick(deckies, _NOW, rand=random.Random(seed))
|
||||
if plan is not None:
|
||||
seen.add(plan.content_class)
|
||||
# Across 80 seeds we should hit both buckets — at least one user
|
||||
# class and at least one system class — otherwise the weights or
|
||||
# the 70/30 split is broken.
|
||||
user_classes = {c for c in seen if c.is_user_class()}
|
||||
system_classes = {c for c in seen if c.is_system_class()}
|
||||
assert user_classes, f"no user-class plans in 80 trials: {seen}"
|
||||
assert system_classes, f"no system-class plans in 80 trials: {seen}"
|
||||
|
||||
|
||||
def test_canary_picks_are_rare() -> None:
|
||||
"""Stage 7: canary content_classes ARE picked, but bounded.
|
||||
|
||||
The documented rate is ~3% of file picks per
|
||||
decnet/realism/planner.py:_CANARY_PROBABILITY. We trial a large
|
||||
sample and assert the rate stays under a generous ceiling so a
|
||||
typo bumping the constant to 30% explodes here loudly.
|
||||
"""
|
||||
deckies = [_decky()]
|
||||
canary_count = 0
|
||||
create_count = 0
|
||||
for seed in range(500):
|
||||
plan = pick(deckies, _NOW, rand=random.Random(seed))
|
||||
if plan is None:
|
||||
continue
|
||||
create_count += 1
|
||||
if plan.content_class.is_canary():
|
||||
canary_count += 1
|
||||
# 3% target with a 6% upper bound — sampling noise on 500 trials
|
||||
# is comfortably below this for the documented rate.
|
||||
rate = canary_count / max(1, create_count)
|
||||
assert rate <= 0.06, f"canary rate {rate:.2%} exceeds 6% ceiling"
|
||||
assert canary_count > 0, "expected at least one canary across 500 seeds"
|
||||
|
||||
|
||||
def test_pick_persists_persona_window_in_notes() -> None:
|
||||
plan = pick([_decky()], _NOW, rand=random.Random(0))
|
||||
assert plan is not None
|
||||
# The plan's notes carry the persona name and the window — useful
|
||||
# for the dashboard's "why this file" inspector.
|
||||
assert any("persona=admin" in n for n in plan.notes)
|
||||
assert any("window=" in n for n in plan.notes)
|
||||
119
tests/realism/test_planner_config.py
Normal file
119
tests/realism/test_planner_config.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Operator-tunable planner knobs (apply_payload / current_payload).
|
||||
|
||||
§3c of the realism handoff: the planner reads mutable module globals
|
||||
that an admin can override via PUT /api/v1/realism/config. These tests
|
||||
pin the validation surface and the payload roundtrip so a regression
|
||||
that breaks operator tunables surfaces here, not on a live fleet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism import planner
|
||||
from decnet.realism.taxonomy import ContentClass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_after_each_test():
|
||||
yield
|
||||
planner.reset_to_defaults()
|
||||
|
||||
|
||||
def test_current_payload_returns_defaults_after_reset():
|
||||
planner.reset_to_defaults()
|
||||
payload = planner.current_payload()
|
||||
assert payload["canary_probability"] == pytest.approx(0.03)
|
||||
user = {e["content_class"]: e["weight"] for e in payload["user_class_weights"]}
|
||||
assert user[ContentClass.NOTE.value] == 30
|
||||
assert user[ContentClass.TODO.value] == 20
|
||||
|
||||
|
||||
def test_apply_payload_overrides_user_weights():
|
||||
planner.apply_payload({
|
||||
"user_class_weights": [
|
||||
{"content_class": "note", "weight": 5},
|
||||
{"content_class": "todo", "weight": 95},
|
||||
],
|
||||
})
|
||||
payload = planner.current_payload()
|
||||
weights = {e["content_class"]: e["weight"] for e in payload["user_class_weights"]}
|
||||
assert weights == {"note": 5, "todo": 95}
|
||||
# System weights left untouched by a partial body.
|
||||
assert payload["system_class_weights"]
|
||||
|
||||
|
||||
def test_apply_payload_overrides_canary_probability():
|
||||
planner.apply_payload({"canary_probability": 0.15})
|
||||
assert planner.current_payload()["canary_probability"] == pytest.approx(0.15)
|
||||
|
||||
|
||||
def test_apply_payload_rejects_bad_canary_probability():
|
||||
with pytest.raises(ValueError, match="canary_probability"):
|
||||
planner.apply_payload({"canary_probability": 1.5})
|
||||
with pytest.raises(ValueError, match="canary_probability"):
|
||||
planner.apply_payload({"canary_probability": -0.1})
|
||||
with pytest.raises(ValueError, match="canary_probability"):
|
||||
planner.apply_payload({"canary_probability": "high"})
|
||||
|
||||
|
||||
def test_apply_payload_rejects_negative_weight():
|
||||
with pytest.raises(ValueError, match="non-negative integer"):
|
||||
planner.apply_payload({
|
||||
"user_class_weights": [{"content_class": "note", "weight": -1}],
|
||||
})
|
||||
|
||||
|
||||
def test_apply_payload_rejects_unknown_content_class():
|
||||
with pytest.raises(ValueError, match="unknown content_class"):
|
||||
planner.apply_payload({
|
||||
"user_class_weights": [{"content_class": "vibes", "weight": 1}],
|
||||
})
|
||||
|
||||
|
||||
def test_apply_payload_drops_class_from_wrong_list():
|
||||
"""A canary class on the user list is silently dropped (operator
|
||||
error), not raised — the partial save still applies the legit
|
||||
entries. Roundtrip shows the operator their entry didn't land."""
|
||||
planner.apply_payload({
|
||||
"user_class_weights": [
|
||||
{"content_class": "note", "weight": 10},
|
||||
{"content_class": "canary_aws_creds", "weight": 100},
|
||||
],
|
||||
})
|
||||
weights = {
|
||||
e["content_class"]: e["weight"]
|
||||
for e in planner.current_payload()["user_class_weights"]
|
||||
}
|
||||
assert weights == {"note": 10}
|
||||
# canary class did NOT bleed onto the user list.
|
||||
assert "canary_aws_creds" not in weights
|
||||
|
||||
|
||||
def test_apply_payload_rejects_zero_total_weight():
|
||||
with pytest.raises(ValueError, match="positive number"):
|
||||
planner.apply_payload({
|
||||
"user_class_weights": [{"content_class": "note", "weight": 0}],
|
||||
})
|
||||
|
||||
|
||||
def test_apply_payload_partial_failure_leaves_state_intact():
|
||||
"""If validation rejects part of a payload, the planner's other
|
||||
fields must not have been silently rebound."""
|
||||
planner.apply_payload({"canary_probability": 0.10})
|
||||
pre = planner.current_payload()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
planner.apply_payload({
|
||||
"user_class_weights": [{"content_class": "note", "weight": 5}],
|
||||
"canary_probability": 9.0, # invalid
|
||||
})
|
||||
|
||||
post = planner.current_payload()
|
||||
assert post == pre # nothing rebound on failure
|
||||
|
||||
|
||||
def test_apply_payload_ignores_unknown_keys():
|
||||
"""Forward-compat: future fields land without breaking older clients."""
|
||||
planner.apply_payload({"future_knob": "ignored"})
|
||||
# Nothing changed.
|
||||
assert planner.current_payload()["canary_probability"] == pytest.approx(0.03)
|
||||
190
tests/realism/test_synthetic_files_repo.py
Normal file
190
tests/realism/test_synthetic_files_repo.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""record / update / list / pick-for-edit on the synthetic_files table.
|
||||
|
||||
Stage 3 of the realism migration introduces the synthetic_files
|
||||
table for per-(decky, path) state. Tests pin the contract on a
|
||||
real :class:`SQLiteRepository` so SQLModel schema bugs surface here
|
||||
rather than in production.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
r = SQLiteRepository(db_path=str(tmp_path / "decnet.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
await r.engine.dispose()
|
||||
|
||||
|
||||
def _row(
|
||||
decky: str = "d1",
|
||||
path: str = "/home/admin/TODO.md",
|
||||
persona: str = "admin",
|
||||
cls: str = "todo",
|
||||
body: str = "- [ ] rotate keys\n",
|
||||
ts: datetime | None = None,
|
||||
) -> dict:
|
||||
now = ts or datetime.now(timezone.utc)
|
||||
return {
|
||||
"decky_uuid": decky,
|
||||
"path": path,
|
||||
"persona": persona,
|
||||
"content_class": cls,
|
||||
"created_at": now,
|
||||
"last_modified": now,
|
||||
"edit_count": 0,
|
||||
"content_hash": hashlib.sha256(body.encode()).hexdigest(),
|
||||
"last_body": body,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_returns_uuid(repo):
|
||||
uuid = await repo.record_synthetic_file(_row())
|
||||
assert isinstance(uuid, str) and uuid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unique_constraint_on_decky_path(repo):
|
||||
await repo.record_synthetic_file(_row())
|
||||
with pytest.raises(Exception):
|
||||
await repo.record_synthetic_file(_row())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_synthetic_file_patches_fields(repo):
|
||||
uuid = await repo.record_synthetic_file(_row())
|
||||
await repo.update_synthetic_file(
|
||||
uuid,
|
||||
{"edit_count": 1, "last_body": "- [x] rotate keys\n"},
|
||||
)
|
||||
listing = await repo.list_synthetic_files(decky_uuid="d1")
|
||||
assert len(listing) == 1
|
||||
assert listing[0]["edit_count"] == 1
|
||||
assert listing[0]["last_body"].startswith("- [x]")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filters_by_decky_and_persona(repo):
|
||||
await repo.record_synthetic_file(_row(decky="d1", path="/a"))
|
||||
await repo.record_synthetic_file(_row(decky="d1", path="/b", persona="ubuntu"))
|
||||
await repo.record_synthetic_file(_row(decky="d2", path="/c"))
|
||||
|
||||
by_decky = await repo.list_synthetic_files(decky_uuid="d1")
|
||||
assert {r["path"] for r in by_decky} == {"/a", "/b"}
|
||||
|
||||
by_persona = await repo.list_synthetic_files(decky_uuid="d1", persona="ubuntu")
|
||||
assert {r["path"] for r in by_persona} == {"/b"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_random_returns_none_when_empty(repo):
|
||||
assert await repo.pick_random_synthetic_file_for_edit("d-empty") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_random_excludes_canary_classes(repo):
|
||||
# Canary-class files are stored on the same table (stage 7) but
|
||||
# the editor must skip them — their bodies are binary blobs.
|
||||
await repo.record_synthetic_file(_row(cls="canary_aws_creds"))
|
||||
picked = await repo.pick_random_synthetic_file_for_edit("d1")
|
||||
assert picked is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_random_excludes_too_old_rows(repo):
|
||||
old = datetime.now(timezone.utc) - timedelta(days=120)
|
||||
await repo.record_synthetic_file(_row(ts=old))
|
||||
picked = await repo.pick_random_synthetic_file_for_edit("d1", max_age_days=30)
|
||||
assert picked is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_random_returns_eligible_row(repo):
|
||||
await repo.record_synthetic_file(_row(cls="todo"))
|
||||
picked = await repo.pick_random_synthetic_file_for_edit("d1")
|
||||
assert picked is not None
|
||||
assert picked["content_class"] == "todo"
|
||||
assert picked["path"] == "/home/admin/TODO.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_synthetic_files_respects_filters(repo):
|
||||
await repo.record_synthetic_file(_row(decky="d1", path="/a", cls="todo"))
|
||||
await repo.record_synthetic_file(_row(decky="d1", path="/b", cls="note"))
|
||||
await repo.record_synthetic_file(_row(decky="d2", path="/c", cls="todo"))
|
||||
assert await repo.count_synthetic_files() == 3
|
||||
assert await repo.count_synthetic_files(decky_uuid="d1") == 2
|
||||
assert await repo.count_synthetic_files(content_class="todo") == 2
|
||||
assert await repo.count_synthetic_files(
|
||||
decky_uuid="d1", content_class="note",
|
||||
) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filters_by_content_class(repo):
|
||||
await repo.record_synthetic_file(_row(decky="d1", path="/a", cls="todo"))
|
||||
await repo.record_synthetic_file(_row(decky="d1", path="/b", cls="note"))
|
||||
rows = await repo.list_synthetic_files(content_class="todo")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["content_class"] == "todo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_synthetic_file_returns_row(repo):
|
||||
uuid = await repo.record_synthetic_file(_row(decky="d1", path="/a"))
|
||||
got = await repo.get_synthetic_file(uuid)
|
||||
assert got is not None
|
||||
assert got["uuid"] == uuid
|
||||
assert got["path"] == "/a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_synthetic_file_returns_none_when_missing(repo):
|
||||
assert await repo.get_synthetic_file("does-not-exist") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realism_config_get_returns_none_when_unset(repo):
|
||||
assert await repo.get_realism_config("weights") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realism_config_set_then_get_roundtrips(repo):
|
||||
await repo.set_realism_config("weights", '{"canary_probability": 0.07}')
|
||||
row = await repo.get_realism_config("weights")
|
||||
assert row is not None
|
||||
assert row["key"] == "weights"
|
||||
assert row["value"] == '{"canary_probability": 0.07}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realism_config_set_is_upsert(repo):
|
||||
await repo.set_realism_config("weights", '{"a": 1}')
|
||||
await repo.set_realism_config("weights", '{"a": 2}')
|
||||
row = await repo.get_realism_config("weights")
|
||||
assert row is not None
|
||||
assert row["value"] == '{"a": 2}'
|
||||
|
||||
|
||||
def test_path_max_length_fits_mysql_utf8mb4_index_limit():
|
||||
"""The unique (decky_uuid, path) index has to fit MySQL's 3072-byte
|
||||
utf8mb4 cap: (decky_uuid_len + path_len) * 4 <= 3072. A regression
|
||||
that widens path past this triggers
|
||||
``Specified key was too long`` on MySQL DB init."""
|
||||
from decnet.web.db.models.realism import SyntheticFile
|
||||
fields = SyntheticFile.model_fields
|
||||
decky_len = fields["decky_uuid"].metadata[0].max_length # type: ignore[attr-defined]
|
||||
path_len = fields["path"].metadata[0].max_length # type: ignore[attr-defined]
|
||||
assert (decky_len + path_len) * 4 <= 3072, (
|
||||
f"(decky_uuid={decky_len} + path={path_len}) * 4 = "
|
||||
f"{(decky_len + path_len) * 4} exceeds MySQL utf8mb4 index cap"
|
||||
)
|
||||
91
tests/realism/test_synthetic_files_truncation.py
Normal file
91
tests/realism/test_synthetic_files_truncation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""``synthetic_files.last_body`` is capped at 64 KB by the repo.
|
||||
|
||||
The repo clips on both insert and update so callers may pass the full
|
||||
body. Large blobs (DOCX/PDF, canary artifacts) would bloat the table;
|
||||
the decky filesystem holds the canonical bytes.
|
||||
|
||||
These tests pin the contract so a regression that drops the cap or
|
||||
applies it inconsistently fails loudly. Note: callers pass the *full*
|
||||
body — the worker no longer clips; the repo does.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.web.db.models.realism import SYNTHETIC_FILE_BODY_LIMIT
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
|
||||
|
||||
_LIMIT = SYNTHETIC_FILE_BODY_LIMIT
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
r = SQLiteRepository(db_path=str(tmp_path / "decnet.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
await r.engine.dispose()
|
||||
|
||||
|
||||
def _row(body: str) -> dict:
|
||||
import hashlib
|
||||
now = datetime.now(timezone.utc)
|
||||
return {
|
||||
"decky_uuid": "d1",
|
||||
"path": "/home/admin/notes.txt",
|
||||
"persona": "admin",
|
||||
"content_class": "note",
|
||||
"created_at": now,
|
||||
"last_modified": now,
|
||||
"edit_count": 0,
|
||||
"content_hash": hashlib.sha256(body.encode("utf-8")).hexdigest(),
|
||||
# Caller passes the full body — the repo clips.
|
||||
"last_body": body,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_clips_oversized_body_at_insert(repo):
|
||||
body = "A" * (_LIMIT * 2)
|
||||
uuid = await repo.record_synthetic_file(_row(body))
|
||||
rows = await repo.list_synthetic_files(decky_uuid="d1")
|
||||
assert len(rows) == 1
|
||||
stored = rows[0]
|
||||
assert stored["uuid"] == uuid
|
||||
assert len(stored["last_body"]) == _LIMIT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_body_at_exact_limit_is_preserved(repo):
|
||||
body = "B" * _LIMIT
|
||||
await repo.record_synthetic_file(_row(body))
|
||||
rows = await repo.list_synthetic_files(decky_uuid="d1")
|
||||
assert len(rows[0]["last_body"]) == _LIMIT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_for_edit_returns_clipped_body(repo):
|
||||
body = "C" * (_LIMIT * 3)
|
||||
await repo.record_synthetic_file(_row(body))
|
||||
candidate = await repo.pick_random_synthetic_file_for_edit("d1")
|
||||
assert candidate is not None
|
||||
assert len(candidate["last_body"]) == _LIMIT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_clips_oversized_body_at_update(repo):
|
||||
uuid = await repo.record_synthetic_file(_row("seed"))
|
||||
big = "D" * (_LIMIT * 4)
|
||||
await repo.update_synthetic_file(
|
||||
uuid,
|
||||
{
|
||||
"last_modified": datetime.now(timezone.utc),
|
||||
"edit_count": 1,
|
||||
"last_body": big,
|
||||
},
|
||||
)
|
||||
rows = await repo.list_synthetic_files(decky_uuid="d1")
|
||||
assert len(rows[0]["last_body"]) == _LIMIT
|
||||
102
tests/realism/test_taxonomy.py
Normal file
102
tests/realism/test_taxonomy.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Coverage for :mod:`decnet.realism.taxonomy`.
|
||||
|
||||
The enum values are persisted on ``synthetic_files.content_class`` and
|
||||
flow through bus topics — renaming a member is a schema change, so the
|
||||
stable-list test pins the wire format. ``Plan`` invariants (frozen,
|
||||
edit requires previous_body) are tested too because the planner relies
|
||||
on construction-time validation rather than a separate validator pass.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism.taxonomy import ContentClass, Plan
|
||||
|
||||
|
||||
def test_content_class_values_are_stable() -> None:
|
||||
# If anyone renames or reorders, the assertion explodes — the
|
||||
# enum is wire-visible (synthetic_files.content_class column,
|
||||
# bus event payloads) so changes need a schema bump elsewhere.
|
||||
assert {c.value for c in ContentClass} == {
|
||||
"note", "todo", "draft", "script",
|
||||
"log_cron", "log_daemon", "cache_tmp",
|
||||
"email",
|
||||
"canary_aws_creds", "canary_env_file", "canary_git_config",
|
||||
"canary_ssh_key", "canary_honeydoc", "canary_honeydoc_docx",
|
||||
"canary_honeydoc_pdf", "canary_mysql_dump",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["NOTE", "TODO", "DRAFT", "SCRIPT"])
|
||||
def test_user_classes_classified(name: str) -> None:
|
||||
cls = ContentClass[name]
|
||||
assert cls.is_user_class()
|
||||
assert not cls.is_system_class()
|
||||
assert not cls.is_canary()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["LOG_CRON", "LOG_DAEMON", "CACHE_TMP"])
|
||||
def test_system_classes_classified(name: str) -> None:
|
||||
cls = ContentClass[name]
|
||||
assert cls.is_system_class()
|
||||
assert not cls.is_user_class()
|
||||
assert not cls.is_canary()
|
||||
|
||||
|
||||
def test_canary_members_all_classified() -> None:
|
||||
canaries = [c for c in ContentClass if c.value.startswith("canary_")]
|
||||
assert canaries, "expected at least one canary content_class"
|
||||
for c in canaries:
|
||||
assert c.is_canary()
|
||||
assert not c.is_user_class()
|
||||
assert not c.is_system_class()
|
||||
|
||||
|
||||
def test_email_is_neither_user_nor_system_nor_canary() -> None:
|
||||
# Email lives on its own track — same content engine but a
|
||||
# different driver and a different table. Classification helpers
|
||||
# must not falsely group it into file-class buckets.
|
||||
assert ContentClass.EMAIL.value == "email"
|
||||
assert not ContentClass.EMAIL.is_user_class()
|
||||
assert not ContentClass.EMAIL.is_system_class()
|
||||
assert not ContentClass.EMAIL.is_canary()
|
||||
|
||||
|
||||
def _plan(**kw):
|
||||
defaults = dict(
|
||||
decky_uuid="d-1",
|
||||
decky_name="alpha",
|
||||
persona="admin",
|
||||
content_class=ContentClass.NOTE,
|
||||
action="create",
|
||||
target_path="/home/admin/notes.txt",
|
||||
mtime=datetime(2026, 4, 25, 11, 30, tzinfo=timezone.utc),
|
||||
body_hint="todo: rotate keys",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return Plan(**defaults)
|
||||
|
||||
|
||||
def test_plan_is_frozen() -> None:
|
||||
p = _plan()
|
||||
with pytest.raises(Exception): # FrozenInstanceError or AttributeError
|
||||
p.persona = "ubuntu" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_edit_plan_requires_previous_body() -> None:
|
||||
with pytest.raises(ValueError, match="previous_body"):
|
||||
_plan(action="edit", previous_body=None)
|
||||
|
||||
|
||||
def test_edit_plan_with_previous_body_succeeds() -> None:
|
||||
p = _plan(action="edit", previous_body="- [ ] rotate keys\n")
|
||||
assert p.action == "edit"
|
||||
assert p.previous_body == "- [ ] rotate keys\n"
|
||||
|
||||
|
||||
def test_create_plan_does_not_need_previous_body() -> None:
|
||||
p = _plan(action="create", previous_body=None)
|
||||
assert p.action == "create"
|
||||
assert p.previous_body is None
|
||||
Reference in New Issue
Block a user