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:
Clément Sirieix 2026-05-07 13:55:56 +02:00 committed by GitHub
parent 4972dd5694
commit b23f49e5f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2342 additions and 240 deletions

304
tests/core/test_loop.py Normal file
View 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