Newer
Older
navi-1 / tests / clients / test_recall_renderer.py
"""Tests for the ``recall_update`` renderer and its chat-model mapping."""

from __future__ import annotations

from rich.console import Console
from rich.panel import Panel
from rich.text import Text

from clients.terminal.tui.renderers.recall import (
    RecallRenderer,
    _format_trigger,
    _preview_message,
)
from clients.terminal.tui.themes import get_active_theme, set_active_theme


def _render_text(renderable) -> str:
    console = Console(record=True, width=100, force_terminal=True, color_system=None)
    console.print(renderable)
    return console.export_text()


def _panel(renderable) -> Panel:
    return renderable


# ── accepts ───────────────────────────────────────────────────────────────────


def test_accepts_recall_update() -> None:
    set_active_theme("gnexus-dark")
    renderer = RecallRenderer()
    assert renderer.accepts({"type": "recall_update", "action": "scheduled"})
    assert not renderer.accepts({"type": "tool_call", "tool": "schedule_recall"})
    assert not renderer.accepts({"type": "status", "content": "x"})


# ── per-action rendering ─────────────────────────────────────────────────────


def test_scheduled_card_title_and_color() -> None:
    set_active_theme("gnexus-dark")
    theme = get_active_theme()
    renderer = RecallRenderer()
    msg = {
        "type": "recall_update",
        "action": "scheduled",
        "call_type": "once",
        "trigger_at": "2026-07-12T18:45:00",
    }
    panel = renderer.render(msg)
    assert isinstance(panel, Panel)
    assert "Recall scheduled" in panel.title
    assert panel.border_style == theme.info.hex
    text = _render_text(panel)
    assert "once" in text
    assert "2026-07-12 18:45:00" in text


def test_fired_card_uses_accent() -> None:
    set_active_theme("gnexus-dark")
    theme = get_active_theme()
    renderer = RecallRenderer()
    msg = {"type": "recall_update", "action": "fired", "call_type": "immediate"}
    panel = renderer.render(msg)
    assert "Recall fired" in panel.title
    assert panel.border_style == theme.accent.hex


def test_cancelled_card_uses_error() -> None:
    set_active_theme("gnexus-dark")
    theme = get_active_theme()
    renderer = RecallRenderer()
    msg = {"type": "recall_update", "action": "cancelled", "call_type": "once"}
    panel = renderer.render(msg)
    assert "Recall cancelled" in panel.title
    assert panel.border_style == theme.error.hex


def test_rescheduled_and_skipped_colors() -> None:
    set_active_theme("gnexus-dark")
    theme = get_active_theme()
    renderer = RecallRenderer()
    resch = renderer.render({"type": "recall_update", "action": "rescheduled"})
    skipped = renderer.render({"type": "recall_update", "action": "skipped"})
    assert resch.border_style == theme.info.hex
    assert skipped.border_style == theme.warning.hex


def test_unknown_action_falls_back_dim() -> None:
    set_active_theme("gnexus-dark")
    theme = get_active_theme()
    renderer = RecallRenderer()
    panel = renderer.render({"type": "recall_update", "action": "weird"})
    assert panel.border_style == theme.text_dim.hex
    assert "weird" in panel.title


# ── trigger formatting ───────────────────────────────────────────────────────


def test_format_trigger_trims_microseconds() -> None:
    assert _format_trigger("2026-07-12T18:45:00.123456") == "2026-07-12 18:45:00"
    assert _format_trigger(None) == ""
    assert _format_trigger("") == ""


def test_body_empty_when_no_details() -> None:
    set_active_theme("gnexus-dark")
    renderer = RecallRenderer()
    panel = renderer.render({"type": "recall_update", "action": "fired"})
    # No call_type / trigger_at → body is an empty Text; title still carries info.
    assert isinstance(panel.renderable, Text)
    assert panel.renderable.plain == ""


# ── self-instruction (message) ────────────────────────────────────────────────


def test_preview_message_single_line() -> None:
    assert _preview_message("Read /tmp/build.log and report errors.") == (
        "Read /tmp/build.log and report errors."
    )


def test_preview_message_multiline_appends_hint() -> None:
    msg = "Read /tmp/build.log.\nIf errors, tail -50 and report.\nThen mark done."
    out = _preview_message(msg)
    assert out is not None
    assert out.startswith("Read /tmp/build.log.")
    assert "(+2 lines)" in out


def test_preview_message_truncates_long_line() -> None:
    long = "x" * 200
    out = _preview_message(long, max_len=40)
    assert out is not None
    assert out.endswith("…")
    assert len(out) <= 40


def test_preview_message_none_when_empty() -> None:
    assert _preview_message(None) is None
    assert _preview_message("") is None


def test_scheduled_card_renders_message() -> None:
    set_active_theme("gnexus-dark")
    renderer = RecallRenderer()
    msg = {
        "type": "recall_update",
        "action": "scheduled",
        "call_type": "once",
        "trigger_at": "2026-07-12T18:45:00",
        "message": "Read /tmp/build.log and report errors.",
    }
    text = _render_text(renderer.render(msg))
    assert "msg:" in text
    assert "Read /tmp/build.log and report errors." in text


def test_card_without_message_has_no_msg_line() -> None:
    set_active_theme("gnexus-dark")
    renderer = RecallRenderer()
    msg = {"type": "recall_update", "action": "fired", "call_type": "immediate"}
    text = _render_text(renderer.render(msg))
    assert "msg:" not in text


# ── chat-model mapping ───────────────────────────────────────────────────────


def test_chat_model_recall_update_creates_recall_item() -> None:
    """A live recall_update event maps to a dedicated ``recall`` ChatItem
    (not the unknown-event status dump), carrying the wire fields in meta."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    item = model.handle_ws_event(
        {
            "type": "recall_update",
            "session_id": "s1",
            "recall_id": "r1",
            "call_type": "once",
            "trigger_at": "2026-07-12T18:45:00",
            "status": "pending",
            "action": "scheduled",
        }
    )
    assert item is not None
    assert item.kind == "recall"
    assert item.meta["action"] == "scheduled"
    assert item.meta["call_type"] == "once"
    assert model.items[-1] is item


def test_chat_panel_item_msg_round_trips_recall() -> None:
    """_item_msg reconstructs the recall_update wire dict from a recall item."""
    from clients.terminal.tui.chat_model import ChatItem
    from clients.terminal.tui.widgets.chat_panel import _item_msg

    item = ChatItem(
        kind="recall",
        meta={"action": "fired", "call_type": "immediate", "status": "fired"},
    )
    msg = _item_msg(item)
    assert msg["type"] == "recall_update"
    assert msg["action"] == "fired"
    assert msg["call_type"] == "immediate"
    assert RecallRenderer().accepts(msg)