166 lines
6.9 KiB
Python
166 lines
6.9 KiB
Python
"""Unit coverage for SPEC-005 self-tasking (TSK-01..08) + OPS-12."""
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock
|
|
|
|
from fjerkroa_bot.persistence import PersistentStore
|
|
from fjerkroa_bot.quota import QuotaLedger
|
|
from fjerkroa_bot.tasks import TaskEngine
|
|
|
|
from .test_spec_ops import OpsBase
|
|
|
|
|
|
class EngineBase(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.store = PersistentStore(Path(self.tmp.name) / "bot.db")
|
|
self.config = {"tasks-enabled": True, "chat-channel": "chat", "taskgen-interval-hours": 0}
|
|
self.ledger = QuotaLedger(self.store, lambda: self.config)
|
|
self.execute = AsyncMock()
|
|
self.propose = AsyncMock(return_value={"task": None})
|
|
self.alert = AsyncMock()
|
|
self.observe = AsyncMock()
|
|
self.idle = lambda: 0.0
|
|
self.engine = TaskEngine(
|
|
self.store,
|
|
self.ledger,
|
|
lambda: self.config,
|
|
self.execute,
|
|
self.propose,
|
|
self.alert,
|
|
lambda: True,
|
|
lambda: self.idle(),
|
|
self.observe,
|
|
)
|
|
|
|
|
|
class TestQueuePersistence(EngineBase):
|
|
async def test_tasks_survive_restart(self):
|
|
"""TSK-01: enqueued tasks are store rows and reload in a fresh store."""
|
|
await self.engine.enqueue("follow-up", "chat", "frag bob nach der pruefung", due_hours=1)
|
|
reborn = PersistentStore(Path(self.tmp.name) / "bot.db")
|
|
tasks = reborn.tasks_open()
|
|
self.assertEqual(len(tasks), 1)
|
|
self.assertEqual((tasks[0]["kind"], tasks[0]["channel"], tasks[0]["state"]), ("follow-up", "chat", "queued"))
|
|
|
|
|
|
class TestExecution(EngineBase):
|
|
async def test_due_task_executes_and_completes(self):
|
|
"""TSK-02: due task runs via execute callback, marked done, observed."""
|
|
await self.engine.enqueue("idle-impulse", "chat", "sag was nettes")
|
|
await self.engine.tick()
|
|
self.execute.assert_awaited_once_with("chat", "sag was nettes")
|
|
self.assertEqual(self.store.tasks_open(), [])
|
|
self.observe.assert_awaited()
|
|
|
|
async def test_failed_execution_marked_failed(self):
|
|
"""TSK-02: execute raising marks the task failed, not done."""
|
|
self.execute.side_effect = RuntimeError("channel gone")
|
|
await self.engine.enqueue("idle-impulse", "chat", "x")
|
|
await self.engine.tick()
|
|
self.assertEqual(self.store.tasks_open(), []) # not queued anymore
|
|
rows = [t for t in self.store.tasks_due()]
|
|
self.assertEqual(rows, [])
|
|
|
|
|
|
class TestDefaultOff(EngineBase):
|
|
async def test_disabled_engine_is_dormant(self):
|
|
"""TSK-03: without tasks-enabled nothing executes or generates."""
|
|
self.store.task_add("idle-impulse", "chat", "2020-01-01 00:00:00", "x")
|
|
self.config.pop("tasks-enabled")
|
|
await self.engine.tick()
|
|
self.execute.assert_not_awaited()
|
|
self.propose.assert_not_awaited()
|
|
|
|
|
|
class TestDailyCap(EngineBase):
|
|
async def test_cap_defers_excess_tasks(self):
|
|
"""TSK-04: over the per-channel cap tasks stay queued."""
|
|
self.config["tasks-max-per-channel-per-day"] = 1
|
|
self.store.task_add("a", "chat", "2020-01-01 00:00:00", "one")
|
|
self.store.task_add("b", "chat", "2020-01-01 00:00:00", "two")
|
|
await self.engine.tick()
|
|
self.assertEqual(self.execute.await_count, 1)
|
|
self.assertEqual(len(self.store.tasks_open()), 1)
|
|
|
|
|
|
class TestApproval(EngineBase):
|
|
async def test_approval_flow(self):
|
|
"""TSK-05: approval mode holds tasks until approved; cancel kills them."""
|
|
self.config["tasks-approval"] = True
|
|
task_id = await self.engine.enqueue("follow-up", "chat", "frag nach")
|
|
self.alert.assert_awaited_once()
|
|
await self.engine.tick()
|
|
self.execute.assert_not_awaited() # approval != due
|
|
self.store.task_set_state(task_id, "queued")
|
|
await self.engine.tick()
|
|
self.execute.assert_awaited_once()
|
|
|
|
|
|
class TestKillSwitch(EngineBase):
|
|
async def test_not_allowed_blocks_tick(self):
|
|
"""TSK-06: bot_initiated_allowed()=False stops execution."""
|
|
self.store.task_add("a", "chat", "2020-01-01 00:00:00", "x")
|
|
engine = TaskEngine(
|
|
self.store, self.ledger, lambda: self.config, self.execute, self.propose, self.alert, lambda: False, lambda: 0.0, self.observe
|
|
)
|
|
await engine.tick()
|
|
self.execute.assert_not_awaited()
|
|
|
|
|
|
class TestIdleImpulse(EngineBase):
|
|
async def test_idle_enqueues_once(self):
|
|
"""TSK-07: long idle enqueues one impulse; pending impulse dedupes."""
|
|
self.config["idle-impulse-hours"] = 1
|
|
self.config["boreness-prompt"] = "denk dir was aus"
|
|
self.idle = lambda: 2 * 3600.0
|
|
await self.engine.tick()
|
|
open_tasks = self.store.tasks_open()
|
|
impulse = [t for t in open_tasks if t["kind"] == "idle-impulse"]
|
|
self.assertEqual(len(impulse), 0) # executed immediately (due now)
|
|
self.execute.assert_awaited_once_with("chat", "denk dir was aus")
|
|
|
|
async def test_short_idle_no_impulse(self):
|
|
"""TSK-07: below the idle threshold nothing is generated."""
|
|
self.config["idle-impulse-hours"] = 12
|
|
self.idle = lambda: 60.0
|
|
await self.engine.tick()
|
|
self.execute.assert_not_awaited()
|
|
|
|
|
|
class TestFollowUpGenerator(EngineBase):
|
|
async def test_proposal_becomes_task(self):
|
|
"""TSK-08: a proposed task is enqueued with its due offset."""
|
|
self.config.pop("chat-channel")
|
|
self.propose.return_value = {"task": {"channel": "chat", "prompt": "frag bob", "due_hours": 24}}
|
|
await self.engine.tick()
|
|
tasks = self.store.tasks_open()
|
|
self.assertEqual(len(tasks), 1)
|
|
self.assertEqual(tasks[0]["kind"], "follow-up")
|
|
|
|
async def test_null_proposal_no_task(self):
|
|
"""TSK-08: null proposal enqueues nothing."""
|
|
self.propose.return_value = {"task": None}
|
|
await self.engine.tick()
|
|
self.assertEqual(self.store.tasks_open(), [])
|
|
|
|
|
|
class TestTaskCommands(OpsBase):
|
|
async def test_list_approve_cancel(self):
|
|
"""OPS-12: !bot tasks lists; task-approve/task-cancel manage states."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
store = PersistentStore(Path(tmp) / "bot.db")
|
|
self.bot.airesponder.store = store
|
|
task_id = store.task_add("follow-up", "chat", "2099-01-01 00:00:00", "frag bob", "approval")
|
|
await self.bot.on_message(self.staff_msg("!bot tasks"))
|
|
listing = self.bot.staff_channel.send.await_args.args[0]
|
|
self.assertIn("follow-up", listing)
|
|
self.assertIn("approval", listing)
|
|
await self.bot.on_message(self.staff_msg(f"!bot task-approve {task_id}"))
|
|
self.assertEqual(store.tasks_open()[0]["state"], "queued")
|
|
await self.bot.on_message(self.staff_msg(f"!bot task-cancel {task_id}"))
|
|
self.assertEqual(store.tasks_open(), [])
|