"""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(), [])