v2.9.5 (#676)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
4972dd5694
commit
b23f49e5f4
74 changed files with 2342 additions and 240 deletions
304
tests/core/test_loop.py
Normal file
304
tests/core/test_loop.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.loop import (
|
||||
MAX_LOOPS_PER_SESSION,
|
||||
MIN_INTERVAL_SECONDS,
|
||||
LoopError,
|
||||
LoopErrorResult,
|
||||
LoopListResult,
|
||||
LoopManager,
|
||||
LoopOkResult,
|
||||
ScheduledLoop,
|
||||
format_duration,
|
||||
parse_interval,
|
||||
)
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
|
||||
|
||||
class FakeMetadata:
|
||||
def __init__(self) -> None:
|
||||
self.loops: list[ScheduledLoop] = []
|
||||
|
||||
|
||||
class FakeSessionLogger:
|
||||
def __init__(self) -> None:
|
||||
self.session_metadata = FakeMetadata()
|
||||
self.persisted: list[list[ScheduledLoop]] = []
|
||||
|
||||
async def persist_loops(self) -> None:
|
||||
self.persisted.append([*self.session_metadata.loops])
|
||||
|
||||
|
||||
class RaisingSessionLogger:
|
||||
def __init__(self) -> None:
|
||||
self.session_metadata = FakeMetadata()
|
||||
|
||||
async def persist_loops(self) -> None:
|
||||
raise RuntimeError("disk on fire")
|
||||
|
||||
|
||||
def _build_manager() -> tuple[LoopManager, FakeSessionLogger]:
|
||||
fake = FakeSessionLogger()
|
||||
return LoopManager(cast(SessionLogger, fake)), fake
|
||||
|
||||
|
||||
class TestParseInterval:
|
||||
def test_parses_supported_units(self) -> None:
|
||||
assert parse_interval("30s") == 30
|
||||
assert parse_interval("5m") == 300
|
||||
assert parse_interval("2h") == 7200
|
||||
assert parse_interval("1d") == 86400
|
||||
|
||||
def test_parses_case_insensitively(self) -> None:
|
||||
assert parse_interval("30S") == 30
|
||||
assert parse_interval("2H") == 7200
|
||||
|
||||
def test_strips_whitespace(self) -> None:
|
||||
assert parse_interval(" 30s ") == 30
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "5", "5x", "-5m", "5 m", "1.5m", "abc"])
|
||||
def test_rejects_invalid_inputs(self, bad: str) -> None:
|
||||
with pytest.raises(LoopError):
|
||||
parse_interval(bad)
|
||||
|
||||
def test_rejects_below_minimum(self) -> None:
|
||||
with pytest.raises(LoopError, match=f"at least {MIN_INTERVAL_SECONDS}"):
|
||||
parse_interval("29s")
|
||||
|
||||
|
||||
class TestFormatInterval:
|
||||
@pytest.mark.parametrize("text", ["30s", "5m", "2h", "1d"])
|
||||
def test_round_trip_with_parse(self, text: str) -> None:
|
||||
assert format_duration(parse_interval(text)) == text
|
||||
|
||||
def test_formats_non_round_values(self) -> None:
|
||||
assert format_duration(90) == "1m30s"
|
||||
assert format_duration(180) == "3m"
|
||||
assert format_duration(7200) == "2h"
|
||||
assert format_duration(259200) == "3d"
|
||||
|
||||
def test_formats_short_version(self) -> None:
|
||||
assert format_duration(90, short=True) == "1m"
|
||||
assert format_duration(180, short=True) == "3m"
|
||||
assert format_duration(190, short=True) == "3m"
|
||||
assert format_duration(8000, short=True) == "2h"
|
||||
assert format_duration(260000, short=True) == "3d"
|
||||
|
||||
|
||||
class TestLoopManagerHandleCommand:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_returns_loops_list(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
result = await manager.handle_command("")
|
||||
assert isinstance(result, LoopListResult)
|
||||
assert result.loops == []
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("verb", ["list", "ls"])
|
||||
async def test_list_alias_returns_loops_list(self, verb: str) -> None:
|
||||
manager, fake = _build_manager()
|
||||
result = await manager.handle_command(verb)
|
||||
assert isinstance(result, LoopListResult)
|
||||
assert result.loops == []
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_success_persists_and_returns_id(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
result = await manager.handle_command("1m hello world")
|
||||
assert isinstance(result, LoopOkResult)
|
||||
assert "every 1m" in result.message
|
||||
assert "hello world" in result.message
|
||||
assert len(manager.loops) == 1
|
||||
loop = manager.loops[0]
|
||||
assert loop.interval_seconds == 60
|
||||
assert loop.prompt == "hello world"
|
||||
assert result.message.count(loop.id) >= 1
|
||||
assert len(fake.persisted) == 1
|
||||
assert fake.persisted[0][0].id == loop.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_bad_interval_returns_error_no_persist(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
result = await manager.handle_command("nope hello")
|
||||
assert isinstance(result, LoopErrorResult)
|
||||
assert "Invalid interval" in result.message
|
||||
assert "Usage:" not in result.message
|
||||
assert manager.loops == []
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_empty_prompt_returns_error_no_persist(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
result = await manager.handle_command("30s ")
|
||||
assert isinstance(result, LoopErrorResult)
|
||||
assert "Missing prompt" in result.message
|
||||
assert manager.loops == []
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_prompt_starting_with_slash_returns_error(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
result = await manager.handle_command("30s /config")
|
||||
assert isinstance(result, LoopErrorResult)
|
||||
assert "cannot start with '/'" in result.message
|
||||
assert manager.loops == []
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_over_limit_returns_error(self) -> None:
|
||||
manager, _ = _build_manager()
|
||||
for i in range(MAX_LOOPS_PER_SESSION):
|
||||
result = await manager.handle_command(f"30s prompt {i}")
|
||||
assert isinstance(result, LoopOkResult)
|
||||
result = await manager.handle_command("30s overflow")
|
||||
assert isinstance(result, LoopErrorResult)
|
||||
assert "limit" in result.message.lower()
|
||||
assert len(manager.loops) == MAX_LOOPS_PER_SESSION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_missing_target_is_error_no_persist(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
result = await manager.handle_command("cancel")
|
||||
assert isinstance(result, LoopErrorResult)
|
||||
assert "Missing loop id" in result.message
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_unknown_id_is_error_no_persist(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
await manager.handle_command("30s ping")
|
||||
fake.persisted.clear()
|
||||
result = await manager.handle_command("cancel deadbeef")
|
||||
assert isinstance(result, LoopErrorResult)
|
||||
assert "deadbeef" in result.message
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_known_id_persists(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
await manager.handle_command("30s ping")
|
||||
loop_id = manager.loops[0].id
|
||||
fake.persisted.clear()
|
||||
result = await manager.handle_command(f"cancel {loop_id}")
|
||||
assert isinstance(result, LoopOkResult)
|
||||
assert loop_id in result.message
|
||||
assert "ping" in result.message
|
||||
assert manager.loops == []
|
||||
assert fake.persisted == [[]]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_persists(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
await manager.handle_command("30s a")
|
||||
await manager.handle_command("30s b")
|
||||
fake.persisted.clear()
|
||||
result = await manager.handle_command("cancel all")
|
||||
assert isinstance(result, LoopOkResult)
|
||||
assert "2 scheduled loop(s)" in result.message
|
||||
assert manager.loops == []
|
||||
assert fake.persisted == [[]]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("verb", ["rm", "stop", "delete"])
|
||||
async def test_cancel_alias_verbs_work(self, verb: str) -> None:
|
||||
manager, _ = _build_manager()
|
||||
await manager.handle_command("30s ping")
|
||||
loop_id = manager.loops[0].id
|
||||
result = await manager.handle_command(f"{verb} {loop_id}")
|
||||
assert isinstance(result, LoopOkResult)
|
||||
assert manager.loops == []
|
||||
|
||||
|
||||
class TestLoopManagerPopDue:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_empty(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
assert await manager.pop_due() is None
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_nothing_due(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
await manager.handle_command("30s ping")
|
||||
fake.persisted.clear()
|
||||
result = await manager.pop_due(now=0.0)
|
||||
assert result is None
|
||||
assert fake.persisted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_due_loop_advances_and_persists(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
await manager.handle_command("30s ping")
|
||||
fake.persisted.clear()
|
||||
loop = manager.loops[0]
|
||||
original_next = loop.next_fire_at
|
||||
due = await manager.pop_due(now=original_next + 100.0)
|
||||
assert due is not None
|
||||
assert due.id == loop.id
|
||||
assert manager.loops[0].next_fire_at == original_next + 100.0 + 30.0
|
||||
assert len(fake.persisted) == 1
|
||||
|
||||
|
||||
class TestLoopManagerNextDueIn:
|
||||
def test_inf_when_empty(self) -> None:
|
||||
manager, _ = _build_manager()
|
||||
assert manager.next_due_in() == math.inf
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_delta_when_populated(self) -> None:
|
||||
manager, _ = _build_manager()
|
||||
await manager.handle_command("30s ping")
|
||||
loop = manager.loops[0]
|
||||
delta = manager.next_due_in(now=loop.next_fire_at - 7.0)
|
||||
assert delta == pytest.approx(7.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_when_overdue(self) -> None:
|
||||
manager, _ = _build_manager()
|
||||
await manager.handle_command("30s ping")
|
||||
loop = manager.loops[0]
|
||||
delta = manager.next_due_in(now=loop.next_fire_at + 100.0)
|
||||
assert delta == 0.0
|
||||
|
||||
|
||||
class TestLoopManagerRestore:
|
||||
def test_replaces_in_memory_list_without_persist(self) -> None:
|
||||
manager, fake = _build_manager()
|
||||
loops = [
|
||||
ScheduledLoop(
|
||||
id="aabbccdd",
|
||||
interval_seconds=30,
|
||||
prompt="x",
|
||||
next_fire_at=1.0,
|
||||
created_at=0.0,
|
||||
),
|
||||
ScheduledLoop(
|
||||
id="11223344",
|
||||
interval_seconds=60,
|
||||
prompt="y",
|
||||
next_fire_at=2.0,
|
||||
created_at=0.0,
|
||||
),
|
||||
]
|
||||
manager.restore(loops)
|
||||
assert [loop.id for loop in manager.loops] == ["aabbccdd", "11223344"]
|
||||
assert fake.persisted == []
|
||||
|
||||
|
||||
class TestLoopManagerPersisterErrors:
|
||||
@pytest.mark.asyncio
|
||||
async def test_persister_exception_does_not_propagate(self) -> None:
|
||||
manager = LoopManager(cast(SessionLogger, RaisingSessionLogger()))
|
||||
result = await manager.handle_command("30s ping")
|
||||
assert isinstance(result, LoopOkResult)
|
||||
assert len(manager.loops) == 1
|
||||
loop = manager.loops[0]
|
||||
due = await manager.pop_due(now=loop.next_fire_at + 1.0)
|
||||
assert due is not None
|
||||
Loading…
Add table
Add a link
Reference in a new issue