"""Tests for ll-loop CLI command."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import patch

import pytest

from little_loops.cli.loop._helpers import EXIT_CODES, run_foreground
from little_loops.cli.loop.info import _render_fsm_diagram
from little_loops.cli.loop.layout import (
    _ACTION_TYPE_BADGES,
    _ROUTE_BADGE,
    _SUB_LOOP_BADGE,
    _collect_edges,
    _get_state_badge,
)
from little_loops.fsm.executor import ExecutionResult
from little_loops.fsm.schema import (
    EvaluateConfig,
    FSMLoop,
    RouteConfig,
    StateConfig,
)

if TYPE_CHECKING:
    pass


class MockExecutor:
    """Mock executor that emits a fixed sequence of events then returns a result."""

    def __init__(self, events: list[dict[str, Any]], loops_dir: Path = Path(".")) -> None:
        self._events = events
        self._on_event: Any = None
        self.loops_dir = loops_dir

    def run(self) -> ExecutionResult:
        for event in self._events:
            if self._on_event:
                self._on_event(event)
        return ExecutionResult(
            final_state="done",
            iterations=1,
            terminated_by="terminal",
            duration_ms=100,
            captured={},
        )


def make_test_state(
    action: str | None = None,
    on_yes: str | None = None,
    on_no: str | None = None,
    on_error: str | None = None,
    next: str | None = None,
    terminal: bool = False,
    evaluate: EvaluateConfig | None = None,
    route: RouteConfig | None = None,
    capture: str | None = None,
    timeout: int | None = None,
    on_maintain: str | None = None,
) -> StateConfig:
    """Create StateConfig for testing."""
    return StateConfig(
        action=action,
        on_yes=on_yes,
        on_no=on_no,
        on_error=on_error,
        next=next,
        terminal=terminal,
        evaluate=evaluate,
        route=route,
        capture=capture,
        timeout=timeout,
        on_maintain=on_maintain,
    )


def make_test_fsm(
    name: str = "test-loop",
    initial: str = "start",
    states: dict[str, StateConfig] | None = None,
    max_iterations: int = 50,
    timeout: int | None = None,
) -> FSMLoop:
    """Create FSMLoop for testing."""
    if states is None:
        states = {
            "start": make_test_state(action="echo start", on_yes="done", on_no="done"),
            "done": make_test_state(terminal=True),
        }
    return FSMLoop(
        name=name,
        initial=initial,
        states=states,
        max_iterations=max_iterations,
        timeout=timeout,
    )


class TestStateToDict:
    """Tests for StateConfig.to_dict() method using real StateConfig objects."""

    def test_simple_state_with_action(self) -> None:
        """Convert state with action and on_success."""
        state = make_test_state(action="echo hello", on_yes="done")
        result = state.to_dict()
        assert result == {"action": "echo hello", "on_yes": "done"}

    def test_terminal_state(self) -> None:
        """Convert terminal state to dict."""
        state = make_test_state(terminal=True)
        result = state.to_dict()
        assert result == {"terminal": True}

    def test_state_with_failure_routing(self) -> None:
        """Convert state with on_failure."""
        state = make_test_state(
            action="pytest",
            on_yes="done",
            on_no="fix",
        )
        result = state.to_dict()
        assert result == {
            "action": "pytest",
            "on_yes": "done",
            "on_no": "fix",
        }

    def test_state_with_on_error(self) -> None:
        """Convert state with on_error."""
        state = make_test_state(
            action="risky_command",
            on_yes="done",
            on_error="handle_error",
        )
        result = state.to_dict()
        assert result == {
            "action": "risky_command",
            "on_yes": "done",
            "on_error": "handle_error",
        }

    def test_state_with_next(self) -> None:
        """Convert state with unconditional next."""
        state = make_test_state(action="echo step", next="next_state")
        result = state.to_dict()
        assert result == {"action": "echo step", "next": "next_state"}

    def test_state_with_evaluate_exit_code(self) -> None:
        """Convert state with exit_code evaluator."""
        state = make_test_state(
            action="pytest",
            evaluate=EvaluateConfig(type="exit_code"),
            on_yes="done",
            on_no="fix",
        )
        result = state.to_dict()
        assert result == {
            "action": "pytest",
            "evaluate": {"type": "exit_code"},
            "on_yes": "done",
            "on_no": "fix",
        }

    def test_state_with_evaluate_numeric(self) -> None:
        """Convert state with output_numeric evaluator."""
        state = make_test_state(
            action="wc -l errors.log",
            evaluate=EvaluateConfig(
                type="output_numeric",
                operator="le",
                target=5,
            ),
            on_yes="done",
            on_no="fix",
        )
        result = state.to_dict()
        assert result == {
            "action": "wc -l errors.log",
            "evaluate": {
                "type": "output_numeric",
                "operator": "le",
                "target": 5,
            },
            "on_yes": "done",
            "on_no": "fix",
        }

    def test_state_with_evaluate_convergence(self) -> None:
        """Convert state with convergence evaluator."""
        state = make_test_state(
            action="count_errors",
            evaluate=EvaluateConfig(
                type="convergence",
                target=0,
                tolerance=0.1,
                previous="${captured.last_count}",
            ),
            on_yes="done",
            on_no="fix",
        )
        result = state.to_dict()
        assert result["evaluate"]["type"] == "convergence"
        assert result["evaluate"]["target"] == 0
        assert result["evaluate"]["tolerance"] == 0.1
        assert result["evaluate"]["previous"] == "${captured.last_count}"

    def test_state_with_evaluate_pattern(self) -> None:
        """Convert state with output_contains evaluator."""
        state = make_test_state(
            action="grep ERROR log.txt",
            evaluate=EvaluateConfig(
                type="output_contains",
                pattern="ERROR",
            ),
            on_yes="fix",
            on_no="done",
        )
        result = state.to_dict()
        assert result["evaluate"]["type"] == "output_contains"
        assert result["evaluate"]["pattern"] == "ERROR"

    def test_state_with_evaluate_json_path(self) -> None:
        """Convert state with output_json evaluator."""
        state = make_test_state(
            action="curl api/status",
            evaluate=EvaluateConfig(
                type="output_json",
                path=".status",
                target="healthy",
            ),
            on_yes="done",
            on_no="retry",
        )
        result = state.to_dict()
        assert result["evaluate"]["type"] == "output_json"
        assert result["evaluate"]["path"] == ".status"
        assert result["evaluate"]["target"] == "healthy"

    def test_state_with_route_table(self) -> None:
        """Convert state with route table."""
        state = make_test_state(
            action="analyze",
            evaluate=EvaluateConfig(type="llm_structured"),
            route=RouteConfig(
                routes={"yes": "done", "no": "retry", "blocked": "escalate"},
                default="error_state",
            ),
        )
        result = state.to_dict()
        assert result["route"] == {
            "yes": "done",
            "no": "retry",
            "blocked": "escalate",
            "_": "error_state",
        }

    def test_state_with_route_no_default(self) -> None:
        """Convert state with route table but no default."""
        state = make_test_state(
            action="check",
            route=RouteConfig(routes={"pass": "done", "fail": "fix"}),
        )
        result = state.to_dict()
        assert result["route"] == {"pass": "done", "fail": "fix"}
        assert "_" not in result["route"]

    def test_state_with_capture(self) -> None:
        """Convert state with capture variable."""
        state = make_test_state(
            action="wc -l errors.log",
            capture="error_count",
            on_yes="check",
        )
        result = state.to_dict()
        assert result["capture"] == "error_count"

    def test_state_with_timeout(self) -> None:
        """Convert state with timeout."""
        state = make_test_state(
            action="slow_command",
            timeout=300,
            on_yes="done",
        )
        result = state.to_dict()
        assert result["timeout"] == 300

    def test_state_with_on_maintain(self) -> None:
        """Convert state with on_maintain."""
        state = make_test_state(
            action="monitor",
            on_maintain="monitor",
            on_yes="done",
        )
        result = state.to_dict()
        assert result["on_maintain"] == "monitor"

    def test_all_fields_populated(self) -> None:
        """Convert state with all optional fields populated."""
        state = make_test_state(
            action="full_test",
            evaluate=EvaluateConfig(
                type="output_numeric",
                operator="eq",
                target=0,
            ),
            on_yes="done",
            on_no="fix",
            on_error="error_handler",
            capture="result",
            timeout=60,
        )
        result = state.to_dict()
        assert "action" in result
        assert "evaluate" in result
        assert "on_yes" in result
        assert "on_no" in result
        assert "on_error" in result
        assert "capture" in result
        assert "timeout" in result


class TestPrintExecutionPlan:
    """Tests for print_execution_plan output formatting.

    Note: print_execution_plan is a nested function in main_loop(), so we test
    via the CLI's --dry-run flag which calls it.
    """

    def test_basic_plan_shows_states(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Plan output shows all states."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        (loops_dir / "test.yaml").write_text("""
name: test
initial: start
states:
  start:
    action: "echo start"
    on_yes: done
  done:
    terminal: true
""")
        monkeypatch.chdir(tmp_path)
        with patch.object(sys, "argv", ["ll-loop", "run", "test", "--dry-run"]):
            from little_loops.cli import main_loop

            main_loop()

        captured = capsys.readouterr()
        assert "[start]" in captured.out
        assert "[done]" in captured.out

    def test_terminal_state_marker(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Terminal states marked with [TERMINAL]."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        (loops_dir / "test.yaml").write_text("""
name: test
initial: done
states:
  done:
    terminal: true
""")
        monkeypatch.chdir(tmp_path)
        with patch.object(sys, "argv", ["ll-loop", "run", "test", "--dry-run"]):
            from little_loops.cli import main_loop

            main_loop()

        captured = capsys.readouterr()
        assert "[TERMINAL]" in captured.out

    def test_long_action_truncated(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Actions over 70 chars are truncated with ..."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        long_action = "echo " + "x" * 100  # 105 chars total
        (loops_dir / "test.yaml").write_text(f"""
name: test
initial: start
states:
  start:
    action: "{long_action}"
    on_yes: done
  done:
    terminal: true
""")
        monkeypatch.chdir(tmp_path)
        with patch.object(sys, "argv", ["ll-loop", "run", "test", "--dry-run"]):
            from little_loops.cli import main_loop

            main_loop()

        captured = capsys.readouterr()
        # Should be truncated at 70 chars with ...
        assert "..." in captured.out
        # Full action should NOT appear
        assert long_action not in captured.out

    def test_prompt_action_shows_3_lines_in_dry_run(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Prompt action type shows first 3 lines in dry-run execution plan."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        long_prompt = "\n".join(f"Line {i}: " + "x" * 50 for i in range(10))
        yaml_content = "name: test\ninitial: fix\nstates:\n  fix:\n    action: |\n"
        for line in long_prompt.splitlines():
            yaml_content += f"      {line}\n"
        yaml_content += "    action_type: prompt\n    on_yes: done\n  done:\n    terminal: true\n"
        (loops_dir / "test.yaml").write_text(yaml_content)
        monkeypatch.chdir(tmp_path)
        with patch.object(sys, "argv", ["ll-loop", "run", "test", "--dry-run"]):
            from little_loops.cli import main_loop

            main_loop()

        captured = capsys.readouterr()
        assert "..." in captured.out
        assert "Line 0" in captured.out
        assert "Line 9" not in captured.out

    def test_evaluate_type_shown(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Evaluate type is displayed."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        (loops_dir / "test.yaml").write_text("""
name: test
initial: check
states:
  check:
    action: "pytest"
    evaluate:
      type: exit_code
    on_yes: done
    on_no: fix
  fix:
    action: "fix.sh"
    next: check
  done:
    terminal: true
""")
        monkeypatch.chdir(tmp_path)
        with patch.object(sys, "argv", ["ll-loop", "run", "test", "--dry-run"]):
            from little_loops.cli import main_loop

            main_loop()

        captured = capsys.readouterr()
        assert "evaluate: exit_code" in captured.out

    def test_route_mappings_displayed(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Route mappings are displayed correctly."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        (loops_dir / "test.yaml").write_text("""
name: test
initial: analyze
states:
  analyze:
    action: "check status"
    route:
      success: done
      failure: retry
      _: error
  done:
    terminal: true
  retry:
    action: "retry"
    next: analyze
  error:
    terminal: true
""")
        monkeypatch.chdir(tmp_path)
        with patch.object(sys, "argv", ["ll-loop", "run", "test", "--dry-run"]):
            from little_loops.cli import main_loop

            main_loop()

        captured = capsys.readouterr()
        assert "route:" in captured.out
        assert "success -> done" in captured.out
        assert "failure -> retry" in captured.out
        assert "_ -> error" in captured.out

    def test_metadata_shown(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Loop metadata (initial, max_iterations, timeout) shown."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        (loops_dir / "test.yaml").write_text("""
name: test
initial: start
max_iterations: 25
timeout: 3600
states:
  start:
    terminal: true
""")
        monkeypatch.chdir(tmp_path)
        with patch.object(sys, "argv", ["ll-loop", "run", "test", "--dry-run"]):
            from little_loops.cli import main_loop

            main_loop()

        captured = capsys.readouterr()
        assert "Initial state: start" in captured.out
        assert "Max iterations: 25" in captured.out
        assert "Timeout: 3600s" in captured.out


class TestProgressDisplay:
    """Tests for progress display formatting."""

    @pytest.mark.parametrize(
        "duration_ms,expected",
        [
            (5200, "5.2s"),
            (30000, "30.0s"),
            (59900, "59.9s"),
        ],
    )
    def test_duration_seconds(self, duration_ms: int, expected: str) -> None:
        """Duration under 60s formatted as seconds."""
        duration_sec = duration_ms / 1000
        assert duration_sec < 60
        duration_str = f"{duration_sec:.1f}s"
        assert duration_str == expected

    @pytest.mark.parametrize(
        "duration_ms,expected",
        [
            (60000, "1m 0s"),
            (90000, "1m 30s"),
            (150000, "2m 30s"),
            (3600000, "60m 0s"),
        ],
    )
    def test_duration_minutes(self, duration_ms: int, expected: str) -> None:
        """Duration over 60s formatted as minutes."""
        duration_sec = duration_ms / 1000
        assert duration_sec >= 60
        minutes = int(duration_sec // 60)
        seconds = duration_sec % 60
        duration_str = f"{minutes}m {seconds:.0f}s"
        assert duration_str == expected

    @pytest.mark.parametrize(
        "verdict,expected_symbol",
        [
            ("yes", "\u2713"),
            ("target", "\u2713"),
            ("progress", "\u2713"),
            ("no", "\u2717"),
            ("stall", "\u2717"),
            ("error", "\u2717"),
            ("blocked", "\u2717"),
        ],
    )
    def test_verdict_symbols(self, verdict: str, expected_symbol: str) -> None:
        """Correct symbols for success/failure verdicts."""
        success_verdicts = ("yes", "target", "progress")
        symbol = "\u2713" if verdict in success_verdicts else "\u2717"
        assert symbol == expected_symbol

    @pytest.mark.parametrize(
        "action_length,expect_truncation",
        [
            (50, False),
            (120, False),
            (121, True),
            (150, True),
            (200, True),
        ],
    )
    def test_action_truncation(self, action_length: int, expect_truncation: bool) -> None:
        """Actions over 120 chars are truncated with ellipsis."""
        action = "x" * action_length
        action_display = action[:120] + "..." if len(action) > 120 else action
        if expect_truncation:
            assert len(action_display) == 123  # 120 chars + "..."
            assert action_display.endswith("...")
        else:
            assert action_display == action
            assert "..." not in action_display

    def test_confidence_formatting(self) -> None:
        """Confidence value formatted to 2 decimal places."""
        confidence = 0.875
        formatted = f"(confidence: {confidence:.2f})"
        assert formatted == "(confidence: 0.88)"

    def test_iteration_progress_format(self) -> None:
        """Iteration progress shows [current/max] format."""
        current = 5
        max_iter = 50
        progress = f"[{current}/{max_iter}]"
        assert progress == "[5/50]"


class TestRenderFsmDiagram:
    """Tests for _render_fsm_diagram() output."""

    def _make_fsm(
        self,
        name: str = "test",
        initial: str = "start",
        states: dict[str, StateConfig] | None = None,
    ) -> FSMLoop:
        return FSMLoop(name=name, initial=initial, states=states or {}, max_iterations=50)

    def test_single_terminal_state(self) -> None:
        """Single terminal state renders just the state box."""
        fsm = self._make_fsm(
            initial="done",
            states={"done": StateConfig(terminal=True)},
        )
        result = _render_fsm_diagram(fsm)
        assert "done" in result
        assert "\u250c" in result  # box top-left corner

    def test_linear_flow_shows_labels(self) -> None:
        """Linear A->B->C shows transition labels in main flow line."""
        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step a", on_yes="b"),
                "b": StateConfig(action="step b", on_yes="c"),
                "c": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        # All three states appear in boxes
        assert "a" in result
        assert "b" in result
        assert "c" in result
        # Transition labels are shown
        assert "yes" in result
        # Box-drawing characters present
        assert "\u250c" in result

    def test_next_transition_label(self) -> None:
        """Unconditional next transition shows 'next' label."""
        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="echo", next="b"),
                "b": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        assert "next" in result

    def test_branching_fsm_shows_branches_section(self) -> None:
        """Failure branch not on main path appears visually as 2D box."""
        fsm = self._make_fsm(
            initial="test",
            states={
                "test": StateConfig(action="pytest", on_yes="done", on_no="fix"),
                "fix": StateConfig(action="fix.sh", on_yes="done"),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        # Main flow states in boxes
        assert "test" in result
        assert "done" in result
        assert "yes" in result
        # Branch edges shown
        assert "no" in result
        assert "\u25b6" in result  # arrow head ▶
        # Off-path state rendered as box (not just text)
        lines = result.split("\n")
        fix_box_lines = [line for line in lines if "fix" in line and "\u2502" in line]
        assert fix_box_lines, "fix should be rendered inside a box with │ borders"

    def test_cyclic_fsm_shows_back_edges_section(self) -> None:
        """Back-edge (retry loop) rendered with 2D vertical connectors."""
        fsm = self._make_fsm(
            initial="evaluate",
            states={
                "evaluate": StateConfig(action="check", on_yes="done", on_no="fix"),
                "fix": StateConfig(action="fix.sh", on_yes="evaluate"),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        # fix -> evaluate back-edge: both states and label appear
        assert "evaluate" in result
        assert "fix" in result
        assert "no" in result
        # Vertical connectors present for routed edges
        assert "\u2502" in result  # │ vertical connector
        # Off-path box rendered with box-drawing chars
        assert "\u25b6" in result or "\u25bc" in result  # ▶ or ▼ arrow
        # Corner characters at pipe-to-horizontal turns
        assert "\u2514" in result or "\u250c" in result  # └ or ┌ corner chars

    def test_self_loop_annotated(self) -> None:
        """Self-loop transition is annotated with loop indicator."""
        fsm = self._make_fsm(
            initial="monitor",
            states={
                "monitor": StateConfig(
                    action="check",
                    on_yes="done",
                    on_no="monitor",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        assert "\u21ba" in result  # ↺ self-loop indicator

    def test_route_table_branches(self) -> None:
        """Route table verdicts appear for non-main-flow targets."""
        fsm = self._make_fsm(
            initial="route_state",
            states={
                "route_state": StateConfig(
                    action="analyze",
                    route=RouteConfig(
                        routes={"pass": "done", "fail": "retry", "skip": "done"},
                        default=None,
                    ),
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(action="retry", on_yes="done"),
            },
        )
        result = _render_fsm_diagram(fsm)
        # Main path states in boxes
        assert "route_state" in result
        assert "done" in result
        # Route labels appear
        assert "pass" in result
        assert "fail" in result
        assert "skip" in result

    def test_main_flow_order(self) -> None:
        """Main flow states appear in top-to-bottom order (vertical layout)."""
        fsm = self._make_fsm(
            initial="first",
            states={
                "first": StateConfig(action="a", on_yes="second"),
                "second": StateConfig(action="b", on_yes="third"),
                "third": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        lines = result.split("\n")
        # Find the row index where each state name appears in a box (with │ border)
        first_row = next(i for i, ln in enumerate(lines) if "first" in ln and "\u2502" in ln)
        second_row = next(i for i, ln in enumerate(lines) if "second" in ln and "\u2502" in ln)
        third_row = next(i for i, ln in enumerate(lines) if "third" in ln and "\u2502" in ln)
        assert first_row < second_row < third_row

    def test_bidirectional_back_edge_both_pipes_on_label_rows(self) -> None:
        """Both │ pipes appear on each label row when connector has up+down edges."""
        fsm = self._make_fsm(
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="check",
                    on_yes="done",
                    on_no="fix",
                    on_error="fix",
                ),
                "fix": StateConfig(action="fix.sh", next="evaluate"),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        # All edges and states appear in diagram
        assert "evaluate" in result
        assert "fix" in result
        assert "done" in result
        assert "no" in result or "no/error" in result
        assert "next" in result
        assert "yes" in result

    def test_multiple_off_path_states_same_depth(self) -> None:
        """Two off-path states appear in boxes with back-edges to main path."""
        # Mirrors fix-quality-and-tests: two check→fix pairs with fix-tests→check-quality cross-edge
        fsm = self._make_fsm(
            initial="check-quality",
            states={
                "check-quality": StateConfig(
                    action="lint",
                    on_yes="check-tests",
                    on_no="fix-quality",
                ),
                "fix-quality": StateConfig(action="fix lint", next="check-quality"),
                "check-tests": StateConfig(
                    action="pytest",
                    on_yes="done",
                    on_no="fix-tests",
                ),
                "fix-tests": StateConfig(action="fix tests", next="check-quality"),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        lines = result.split("\n")

        # Both off-path states must appear in boxes
        fix_quality_row = next(
            (i for i, ln in enumerate(lines) if "fix-quality" in ln and "\u2502" in ln), None
        )
        fix_tests_row = next(
            (i for i, ln in enumerate(lines) if "fix-tests" in ln and "\u2502" in ln), None
        )
        assert fix_quality_row is not None, "fix-quality box not found"
        assert fix_tests_row is not None, "fix-tests box not found"

        # In layered layout, fix-quality and fix-tests are at different depths
        # (fix-quality at depth 1, fix-tests at depth 2). Both should be visible.
        # Back-edges should be rendered (▲ for upward arrows in margin)
        assert "\u25b6" in result, (
            "Expected \u25b6 (▶) margin back-edge arrow for back-edges to check-quality"
        )

    def test_linear_off_path_chain_all_states_visible(self) -> None:
        """All states in a linear off-path chain appear as distinct non-overlapping boxes.

        Regression for BUG-658: fix/check_commit/commit form a chain below evaluate,
        all back-anchored to the same main-path state. In the layered layout, these
        states appear at increasing depths (top-to-bottom), not side-by-side.
        """
        fsm = self._make_fsm(
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="check",
                    on_yes="done",
                    on_no="fix",
                    on_partial="evaluate",
                ),
                "fix": StateConfig(action="fix", next="check_commit"),
                "check_commit": StateConfig(
                    action="check-c",
                    on_yes="commit",
                    on_no="evaluate",
                ),
                "commit": StateConfig(action="commit", next="evaluate"),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        lines = result.split("\n")

        # All 4 non-terminal states must appear in a box line
        for state in ("evaluate", "fix", "check_commit", "commit"):
            box_lines = [ln for ln in lines if state in ln and "\u2502" in ln]
            assert box_lines, f"{state!r} should be rendered in a box with \u2502 borders"

        # In layered layout, off-path states are at different vertical depths (top-to-bottom)
        fix_row = next(i for i, ln in enumerate(lines) if "fix" in ln and "\u2502" in ln)
        cc_row = next(i for i, ln in enumerate(lines) if "check_commit" in ln and "\u2502" in ln)
        commit_row = next(
            i
            for i, ln in enumerate(lines)
            if "commit" in ln and "check_commit" not in ln and "\u2502" in ln
        )
        assert fix_row < cc_row < commit_row, (
            f"Off-path states should appear top-to-bottom; "
            f"fix={fix_row}, check_commit={cc_row}, commit={commit_row}"
        )

    def test_issue_refinement_git_topology(self) -> None:
        """Regression for BUG-664: 6-state issue-refinement topology renders correctly.

        Tests that:
        - All 6 states appear in boxes
        - Back-edges from check_commit and commit route to evaluate (correct target)
        - Both ↺ partial and ↺ error self-loops appear for evaluate
        - States are laid out vertically in correct order
        """
        fsm = self._make_fsm(
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="evaluate",
                    on_yes="evaluate",
                    on_no="format_issues",
                    on_partial="evaluate",
                    on_error="evaluate",
                ),
                "format_issues": StateConfig(action="format", next="score_issues"),
                "score_issues": StateConfig(action="score", next="refine_issues"),
                "refine_issues": StateConfig(action="refine", next="check_commit"),
                "check_commit": StateConfig(
                    action="check",
                    on_yes="commit",
                    on_no="evaluate",
                    on_error="evaluate",
                ),
                "commit": StateConfig(action="commit", next="evaluate"),
            },
        )
        result = _render_fsm_diagram(fsm)
        lines = result.split("\n")

        # 1. All 6 states appear in boxes (line with state name AND │ border)
        for state in (
            "evaluate",
            "format_issues",
            "score_issues",
            "refine_issues",
            "check_commit",
            "commit",
        ):
            box_lines = [ln for ln in lines if state in ln and "\u2502" in ln]
            assert box_lines, f"{state!r} should be rendered in a box with \u2502 borders"

        # 2. ▶ appears at box connection point (horizontal connector enters box from left)
        right_arrow_count = result.count("\u25b6")
        assert right_arrow_count >= 1, (
            f"Expected at least 1 \u25b6 (▶) at evaluate box connection, "
            f"found {right_arrow_count}. Full diagram:\n{result}"
        )

        # 2b. Corner characters (┌/┬/└) at pipe-to-horizontal turn points
        assert "\u250c" in result or "\u252c" in result, (
            f"Expected \u250c (┌) or \u252c (┬) corner at top of back-edge pipe. "
            f"Full diagram:\n{result}"
        )
        assert "\u2514" in result, (
            f"Expected \u2514 (└) corner where pipe ends. Full diagram:\n{result}"
        )

        # 3. Combined label "no/error" or "error/no" should appear for the merged edge
        assert "no/" in result or "/no" in result, (
            f"Expected combined no/error label for check_commit\u2192evaluate, "
            f"not separate pipes. Full diagram:\n{result}"
        )

        # 4. Both ↺ partial and ↺ error self-loops appear for evaluate
        assert "\u21ba" in result, "Expected \u21ba self-loop marker for evaluate"
        assert "partial" in result, "Expected 'partial' self-loop label"

        # 5. No garbled labels — no line should have a letter immediately after │
        import re

        for ln in lines:
            garbled = re.findall(r"\u2502[a-zA-Z]", ln)
            assert not garbled, (
                f"Garbled label detected (letter touching pipe): {garbled!r} in line: {ln!r}"
            )

        # 6. Box borders should not be overwritten — └ and ┘ should appear in boxes
        border_lines = [ln for ln in lines if "\u2514" in ln or "\u2518" in ln]
        assert border_lines, "Box bottom borders (└/┘) should be present"

        # 7. States appear in top-to-bottom vertical order
        eval_row = next(i for i, ln in enumerate(lines) if "evaluate" in ln and "\u2502" in ln)
        fmt_row = next(i for i, ln in enumerate(lines) if "format_issues" in ln and "\u2502" in ln)
        score_row = next(i for i, ln in enumerate(lines) if "score_issues" in ln and "\u2502" in ln)
        assert eval_row < fmt_row < score_row, "States should appear top-to-bottom"

    def test_main_path_cycle_renders_back_edge(self) -> None:
        """Main-path cycle edge (last → initial) renders as left-margin back-edge.

        Regression test: when the main path forms a cycle (e.g. commit → start
        via 'next'), the backward edge must render as a left-margin back-edge
        arrow, not be silently dropped.
        """
        fsm = self._make_fsm(
            initial="start",
            states={
                "start": StateConfig(action="count", next="work"),
                "work": StateConfig(action="do", on_yes="decide"),
                "decide": StateConfig(action="eval", on_yes="commit", on_no="done"),
                "commit": StateConfig(action="save", next="start"),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)

        # Back-edge from commit → start must have left-margin corner characters
        assert "┌" in result or "┬" in result, (
            f"Main-path cycle should render as back-edge with top corner.\n{result}"
        )
        # The ▶ connector entering the target box
        assert "▶" in result, f"Main-path cycle back-edge should have ▶ connector.\n{result}"
        # All states should be visible
        for state in ("start", "work", "decide", "commit", "done"):
            box_lines = [ln for ln in result.split("\n") if state in ln and "│" in ln]
            assert box_lines, f"{state!r} should be rendered in a box"

    def test_branch_to_terminal_skip_layer_renders_edge(self) -> None:
        """Branch edge to terminal state spanning multiple layers renders as right-margin edge.

        Regression test (BUG-678): when a branch edge (on_failure/on_error)
        targets a terminal state that is 2+ layers away, the forward skip-layer
        edge must render as a right-margin arrow, not be silently dropped.

        Topology: start → work → evaluate → commit → cleanup → done
        with evaluate.on_no → done.  Longest-path assignment places
        done at layer 5 (via cleanup), making evaluate(2) → done(5) a
        forward skip-layer edge spanning 3 layers.
        """
        fsm = self._make_fsm(
            initial="start",
            states={
                "start": StateConfig(action="scan", on_yes="work"),
                "work": StateConfig(action="do", on_yes="evaluate"),
                "evaluate": StateConfig(action="check", on_yes="commit", on_no="done"),
                "commit": StateConfig(action="save", on_yes="cleanup"),
                "cleanup": StateConfig(action="tidy", on_yes="done"),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)

        # Right-margin corner characters for forward skip-layer edge
        assert "┐" in result or "┘" in result or "┤" in result, (
            f"Forward skip-layer edge should render right-margin corners.\n{result}"
        )
        # The ◀ connector entering the target box from the right
        assert "◀" in result, f"Forward skip-layer edge should have ◀ connector.\n{result}"
        # All states should be visible
        for state in ("start", "work", "evaluate", "commit", "cleanup", "done"):
            box_lines = [ln for ln in result.split("\n") if state in ln and "│" in ln]
            assert box_lines, f"{state!r} should be rendered in a box"

    def test_inter_layer_offset_edge_draws_horizontal_connector(self) -> None:
        """Offset inter-layer edge draws ─ connector from source box to pipe.

        When a state branches to two states on the next layer, the off-center
        destination gets a horizontal connector (───┐ or ┌───) linking the
        source box boundary to the vertical pipe.
        """
        # a → b (success) and a → c (failure), both on next layer.
        # b and c share a layer; one pipe aligns with a, the other is offset.
        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step", on_yes="b", on_no="c"),
                "b": StateConfig(terminal=True),
                "c": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)

        # Horizontal connector should appear for the offset edge
        assert "\u2500" in result, (
            f"Offset inter-layer edge should have ─ horizontal connector.\n{result}"
        )
        # Corner piece where horizontal meets vertical
        assert "\u2510" in result or "\u250c" in result, (
            f"Offset inter-layer edge should have ┐ or ┌ corner.\n{result}"
        )

    def test_cross_column_forward_edge_connects_to_source_center(self) -> None:
        """Cross-column forward edge renders corner at source box center.

        When a source box is offset from the destination (e.g. source is in a
        multi-box layer and destination is centered in a single-box layer),
        the horizontal connector must extend to the source box center with
        a └ or ┘ corner character, not stop at the source box edge.
        """
        # a branches to b and c on layer 2.
        # c (right side) transitions to d (single box, centered layer 3).
        # The c→d edge is cross-column: d's center is left of c's left edge.
        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step_a", on_yes="b", on_no="c"),
                "b": StateConfig(terminal=True),
                "c": StateConfig(action="step_c", on_yes="d"),
                "d": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)

        # The connector from d's center to c's center should have └ or ┘
        assert "\u2518" in result or "\u2514" in result, (
            f"Cross-column forward edge should have └ or ┘ corner at source box center.\n{result}"
        )

    def test_skip_layer_forward_edges_sharing_node_connected(self) -> None:
        """Two skip-layer forward edges sharing a node render connected horizontals.

        Regression test (BUG-686): when two forward skip-layer edges share
        a common node, the horizontal connector for the outer pipe must cross
        the inner pipe with a junction character (┴ or ┼), not stop short.

        Topology (main path): a → b → c → d → e → f → g → h
          Edge 1: b --fail--> e  (skips c, d — forward skip-layer)
          Edge 2: e --fail--> h  (skips f, g — forward skip-layer)
        Both are right-margin pipes sharing node e. At e's row, Edge 2's
        horizontal must cross Edge 1's vertical pipe with a junction char.
        Extra layers (f, g) between e and h prevent layer merge.
        """
        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step_a", on_yes="b"),
                "b": StateConfig(action="step_b", on_yes="c", on_no="e"),
                "c": StateConfig(action="step_c", on_yes="d"),
                "d": StateConfig(action="step_d", on_yes="e"),
                "e": StateConfig(action="step_e", on_yes="f", on_no="h"),
                "f": StateConfig(action="step_f", on_yes="g"),
                "g": StateConfig(action="step_g", on_yes="h"),
                "h": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)

        # Both ◀ arrows should render (one per skip-layer target)
        arrow_count = result.count("\u25c0")
        assert arrow_count >= 2, (
            f"Expected at least 2 ◀ arrows for two skip-layer edges, found {arrow_count}.\n{result}"
        )

        # No disconnected gap pattern: ┘ followed by spaces then ┐
        # (this is the symptom of the bug — inner pipe corner stops outer horizontal)
        import re

        for ln in result.split("\n"):
            gap_match = re.search(r"\u2518\s+\u2510", ln)
            assert not gap_match, (
                f"Disconnected gap between inner and outer pipe detected: {ln!r}\n{result}"
            )

        # Junction characters (┴ or ┼) should appear where outer pipe crosses inner
        assert "\u2534" in result or "\u253c" in result, (
            f"Expected ┴ or ┼ crossing junction where outer pipe crosses inner.\n{result}"
        )

    def test_same_layer_edge_does_not_occlude_intermediate_box(self) -> None:
        """Same-layer transition does not draw connector through intermediate state boxes.

        Regression test (BUG-730): when states A, B, C share a layer and there
        is a same-layer transition from A to C, the horizontal connector must
        not overwrite the borders or interior of B.

        Topology: start → a → [b, c, d] (same layer), a --next--> d (same layer,
        skips over b and c).  All three share a row; the A→D connector must not
        write through B or C's cells.
        """
        fsm = self._make_fsm(
            initial="start",
            states={
                "start": StateConfig(
                    action="init",
                    on_yes="b",
                    on_no="c",
                    on_error="d",
                ),
                "b": StateConfig(action="step_b", on_yes="end"),
                "c": StateConfig(action="step_c", on_yes="end", next="d"),
                "d": StateConfig(action="step_d", on_yes="end"),
                "end": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)

        # All states must appear in their own boxes (name visible inside │...│)
        for state in ("start", "b", "c", "d", "end"):
            box_lines = [ln for ln in result.split("\n") if state in ln and "│" in ln]
            assert box_lines, f"{state!r} should be rendered in a box.\n{result}"

        # The intermediate states (b, c) must have their names readable on their
        # name row — not replaced by ─ connector characters from a same-layer edge.
        for state in ("b", "c", "d"):
            # A name-row line looks like: │ state_name ... │
            # If occluded, the line containing the state name would be absent or
            # the state name would be replaced by ─ characters.
            name_lines = [ln for ln in result.split("\n") if state in ln and "│" in ln]
            assert name_lines, (
                f"State {state!r} name occluded by connector: not visible in any box line.\n{result}"
            )

    def test_highlighted_state_uses_configured_color(self) -> None:
        """highlight_state box uses the configured ANSI color; other boxes do not."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step a", on_yes="b"),
                "b": StateConfig(terminal=True),
            },
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm, highlight_state="a", highlight_color="36")

        # Highlighted state box has the color code
        assert "\033[36m" in result
        # Bold variant used for state name
        assert "\033[36;1m" in result

    def test_highlighted_state_default_green(self) -> None:
        """highlight_state defaults to color code '32' (green)."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            initial="start",
            states={"start": StateConfig(terminal=True)},
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm, highlight_state="start")

        assert "\033[32m" in result

    def test_no_highlight_state_unchanged(self) -> None:
        """Without highlight_state, output contains no ANSI codes from box drawing."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step", on_yes="b"),
                "b": StateConfig(terminal=True),
            },
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm)

        # No ANSI codes in box chars (edge labels may add color but not box borders)
        # State names should appear plain
        assert "a" in result
        assert "b" in result
        # No color from box borders (highlight not active)
        assert "\033[32m\u250c" not in result  # no colored top-left corner

    def test_unknown_highlight_state_no_crash(self) -> None:
        """Providing a non-existent state as highlight_state does not crash."""
        fsm = self._make_fsm(
            initial="a",
            states={"a": StateConfig(terminal=True)},
        )
        result = _render_fsm_diagram(fsm, highlight_state="nonexistent", highlight_color="32")
        assert "a" in result

    def test_edge_label_custom_color_applied(self) -> None:
        """Custom edge_label_colors overrides the default ANSI code for known labels."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step a", on_yes="b"),
                "b": StateConfig(terminal=True),
            },
        )
        custom_colors = {
            "yes": "99",
            "no": "38;5;208",
            "error": "31",
            "partial": "33",
            "next": "2",
            "_": "2",
            "blocked": "31",
            "retry_exhausted": "38;5;208",
        }
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm, edge_label_colors=custom_colors)

        # Custom ANSI code for "yes" should appear
        assert "\033[99m" in result

    def test_edge_label_custom_color_overrides_default(self) -> None:
        """When edge_label_colors overrides 'yes', the default green code is not used for it."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step a", on_yes="b"),
                "b": StateConfig(terminal=True),
            },
        )
        # Override "yes" with a unique color code unlikely to appear from any other source
        custom_colors = {
            "yes": "55",
            "no": "38;5;208",
            "error": "31",
            "partial": "33",
            "next": "2",
            "_": "2",
            "blocked": "31",
            "retry_exhausted": "38;5;208",
        }
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm, edge_label_colors=custom_colors)

        assert "\033[55m" in result
        # Default green "32" should not appear as the edge label color for "yes"
        assert "\033[32myes" not in result

    def test_non_highlighted_state_name_bold(self) -> None:
        """Non-highlighted state names are rendered in bold for visual hierarchy."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step a", on_yes="b"),
                "b": StateConfig(terminal=True),
            },
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm)

        # State names should be bold (ANSI SGR 1); no highlight_state set so both boxes are non-highlighted
        assert "\033[1m" in result, "Non-highlighted state names should use bold (ANSI code 1)"


class TestAdaptiveLayoutTopologies:
    """Tests for topology-specific adaptive layout rendering."""

    def _make_fsm(
        self,
        name: str = "test",
        initial: str = "start",
        states: dict[str, StateConfig] | None = None,
    ) -> FSMLoop:
        return FSMLoop(name=name, initial=initial, states=states or {}, max_iterations=50)

    def test_two_state_linear_vertical(self) -> None:
        """2-state linear FSM renders vertically."""
        fsm = self._make_fsm(
            initial="a",
            states={
                "a": StateConfig(action="step", on_yes="b"),
                "b": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        lines = result.split("\n")
        a_row = next(i for i, ln in enumerate(lines) if "a" in ln and "\u2502" in ln)
        b_row = next(i for i, ln in enumerate(lines) if "b" in ln and "\u2502" in ln)
        assert a_row < b_row, "2-state linear should render top-to-bottom"
        assert "\u25bc" in result, "Expected \u25bc vertical arrow between states"

    def test_four_state_linear_vertical(self) -> None:
        """4-state linear FSM renders vertically in correct order."""
        fsm = self._make_fsm(
            initial="s1",
            states={
                "s1": StateConfig(action="a", on_yes="s2"),
                "s2": StateConfig(action="b", on_yes="s3"),
                "s3": StateConfig(action="c", on_yes="s4"),
                "s4": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        lines = result.split("\n")
        rows = {}
        for state in ("s1", "s2", "s3", "s4"):
            rows[state] = next(i for i, ln in enumerate(lines) if state in ln and "\u2502" in ln)
        assert rows["s1"] < rows["s2"] < rows["s3"] < rows["s4"]

    def test_diamond_pattern(self) -> None:
        """Diamond pattern (fan-out + fan-in) renders with branches and convergence."""
        fsm = self._make_fsm(
            initial="start",
            states={
                "start": StateConfig(action="check", on_yes="left", on_no="right"),
                "left": StateConfig(action="path-a", on_yes="end"),
                "right": StateConfig(action="path-b", on_yes="end"),
                "end": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        lines = result.split("\n")
        # All states appear in boxes
        for state in ("start", "left", "right", "end"):
            box_lines = [ln for ln in lines if state in ln and "\u2502" in ln]
            assert box_lines, f"{state!r} should be in a box"
        # Both "yes" and "no" labels appear
        assert "yes" in result
        assert "no" in result

    def test_fan_in_three_paths(self) -> None:
        """Fan-in with 3+ paths converging on a single state."""
        fsm = self._make_fsm(
            initial="dispatch",
            states={
                "dispatch": StateConfig(
                    action="route",
                    route=RouteConfig(routes={"a": "path_a", "b": "path_b", "c": "path_c"}),
                ),
                "path_a": StateConfig(action="a", on_yes="merge"),
                "path_b": StateConfig(action="b", on_yes="merge"),
                "path_c": StateConfig(action="c", on_yes="merge"),
                "merge": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        # All states in boxes
        for state in ("dispatch", "path_a", "path_b", "path_c", "merge"):
            assert state in result, f"{state!r} should appear in diagram"
        # Route labels present
        for lbl in ("a", "b", "c"):
            assert lbl in result

    def test_terminal_width_no_overflow(self) -> None:
        """Diagram lines do not exceed terminal width."""
        from unittest.mock import patch as _patch

        from little_loops.cli import output as output_mod

        # Simulate a narrow terminal
        with _patch.object(output_mod, "terminal_width", return_value=80):
            fsm = self._make_fsm(
                initial="evaluate",
                states={
                    "evaluate": StateConfig(
                        action="check",
                        on_yes="done",
                        on_no="fix",
                        on_partial="evaluate",
                    ),
                    "fix": StateConfig(action="fix", next="evaluate"),
                    "done": StateConfig(terminal=True),
                },
            )
            result = _render_fsm_diagram(fsm)
            for i, line in enumerate(result.split("\n")):
                # Strip ANSI codes for width check
                import re

                clean = re.sub(r"\033\[[0-9;]*m", "", line)
                assert len(clean) <= 80, (
                    f"Line {i} exceeds terminal width (80): {len(clean)} chars: {line!r}"
                )

    def test_multiple_self_loops_all_shown(self) -> None:
        """State with multiple self-loops shows all labels."""
        fsm = self._make_fsm(
            initial="monitor",
            states={
                "monitor": StateConfig(
                    action="check",
                    on_yes="done",
                    on_partial="monitor",
                    on_error="monitor",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        result = _render_fsm_diagram(fsm)
        assert "\u21ba" in result
        assert "partial" in result
        assert "error" in result


class TestDisplayProgressEvents:
    """Tests for display_progress event formatting in run_foreground."""

    def _make_args(
        self,
        quiet: bool = False,
        verbose: bool = False,
        show_diagrams: bool = False,
        clear: bool = False,
    ) -> argparse.Namespace:
        return argparse.Namespace(
            quiet=quiet, verbose=verbose, show_diagrams=show_diagrams, clear=clear
        )

    def _make_fsm(self) -> FSMLoop:
        return make_test_fsm()

    def test_action_complete_exit_124_shown_as_timed_out(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Exit code 124 is displayed as 'timed out' not 'exit: 124'."""
        events = [
            {"event": "action_complete", "duration_ms": 120000, "exit_code": 124},
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args())
        captured = capsys.readouterr()
        assert "timed out" in captured.out
        assert "exit: 124" not in captured.out

    def test_action_complete_nonzero_exit_shown_as_exit_code(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Non-124 non-zero exit codes are displayed as 'exit: N'."""
        events = [
            {"event": "action_complete", "duration_ms": 1000, "exit_code": 1},
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args())
        captured = capsys.readouterr()
        assert "exit: 1" in captured.out
        assert "timed out" not in captured.out

    def test_evaluate_error_shows_raw_preview(self, capsys: pytest.CaptureFixture[str]) -> None:
        """raw_preview is shown below error verdict line."""
        events = [
            {
                "event": "evaluate",
                "verdict": "error",
                "error": "Empty result field in Claude CLI response",
                "raw_preview": '{"is_error": false, "result": ""}',
            },
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args())
        captured = capsys.readouterr()
        assert "raw:" in captured.out
        assert '{"is_error": false' in captured.out

    def test_evaluate_success_no_raw_preview(self, capsys: pytest.CaptureFixture[str]) -> None:
        """raw_preview is not shown for successful verdicts."""
        events = [
            {
                "event": "evaluate",
                "verdict": "yes",
                "confidence": 0.9,
                "raw_preview": "should not appear",
            },
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args())
        captured = capsys.readouterr()
        assert "should not appear" not in captured.out
        assert "raw:" not in captured.out

    def test_verbose_shell_output_printed_once(self, capsys: pytest.CaptureFixture[str]) -> None:
        """In verbose mode, shell action output appears exactly once (not via both action_output and output_preview)."""
        events = [
            {"event": "action_output", "line": "  fmt  | verify"},
            {"event": "action_output", "line": "   \u2713   |   \u2713  "},
            {
                "event": "action_complete",
                "exit_code": 0,
                "duration_ms": 100,
                "output_preview": "  fmt  | verify\n   \u2713   |   \u2713  ",
                "is_prompt": False,
            },
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args(verbose=True))
        out = capsys.readouterr().out
        assert out.count("fmt") == 1

    def test_nonverbose_shell_output_shows_preview(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """In non-verbose mode, shell output_preview is shown (action_output events are suppressed)."""
        events = [
            {"event": "action_output", "line": "streamed line"},
            {
                "event": "action_complete",
                "exit_code": 0,
                "duration_ms": 100,
                "output_preview": "preview line",
                "is_prompt": False,
            },
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args(verbose=False))
        out = capsys.readouterr().out
        assert "preview line" in out
        assert "streamed line" not in out

    def test_show_diagrams_state_enter_prints_diagram(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """--show-diagrams flag causes state_enter events to print the FSM diagram."""
        from unittest.mock import patch

        from little_loops.cli.loop import layout as layout_mod

        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
        ]
        executor = MockExecutor(events)
        with patch.object(
            layout_mod, "_render_fsm_diagram", wraps=layout_mod._render_fsm_diagram
        ) as mock_render:
            run_foreground(executor, self._make_fsm(), self._make_args(show_diagrams=True))
            mock_render.assert_called_once_with(
                self._make_fsm(),
                highlight_state="start",
                highlight_color="32",
                edge_label_colors=None,
            )
        out = capsys.readouterr().out
        # Diagram contains box drawing characters
        assert "\u250c" in out

    def test_verbose_without_show_diagrams_no_diagram(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """--verbose alone does not print the FSM diagram."""
        from unittest.mock import patch

        from little_loops.cli.loop import info as info_mod

        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
        ]
        executor = MockExecutor(events)
        with patch.object(info_mod, "_render_fsm_diagram") as mock_render:
            run_foreground(executor, self._make_fsm(), self._make_args(verbose=True))
            mock_render.assert_not_called()

    def test_no_flags_state_enter_no_diagram(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Without --show-diagrams, state_enter events do not print the FSM diagram."""
        from unittest.mock import patch

        from little_loops.cli.loop import info as info_mod

        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
        ]
        executor = MockExecutor(events)
        with patch.object(info_mod, "_render_fsm_diagram") as mock_render:
            run_foreground(executor, self._make_fsm(), self._make_args(verbose=False))
            mock_render.assert_not_called()

    def test_verbose_and_show_diagrams_prints_diagram(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """--verbose and --show-diagrams combined prints diagram and verbose output."""
        from unittest.mock import patch

        from little_loops.cli.loop import layout as layout_mod

        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
            {"event": "action_output", "line": "verbose output line"},
        ]
        executor = MockExecutor(events)
        with patch.object(
            layout_mod, "_render_fsm_diagram", wraps=layout_mod._render_fsm_diagram
        ) as mock_render:
            run_foreground(
                executor, self._make_fsm(), self._make_args(verbose=True, show_diagrams=True)
            )
            mock_render.assert_called_once_with(
                self._make_fsm(),
                highlight_state="start",
                highlight_color="32",
                edge_label_colors=None,
            )
        out = capsys.readouterr().out
        assert "\u250c" in out
        assert "verbose output line" in out

    def test_clear_flag_emits_ansi_clear_when_tty(self, capsys: pytest.CaptureFixture[str]) -> None:
        """--clear flag emits ANSI clear-screen escape when stdout is a tty."""
        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
        ]
        executor = MockExecutor(events)
        with patch("sys.stdout.isatty", return_value=True):
            run_foreground(executor, self._make_fsm(), self._make_args(clear=True))
        out = capsys.readouterr().out
        assert "\033[2J\033[H" in out

    def test_clear_flag_suppressed_when_not_tty(self, capsys: pytest.CaptureFixture[str]) -> None:
        """--clear flag does not emit ANSI sequences when stdout is not a tty."""
        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
        ]
        executor = MockExecutor(events)
        with patch("sys.stdout.isatty", return_value=False):
            run_foreground(executor, self._make_fsm(), self._make_args(clear=True))
        out = capsys.readouterr().out
        assert "\033[2J" not in out

    def test_clear_flag_suppressed_for_sub_loop_state_enter(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """--clear flag does NOT emit clear-screen for depth>0 (sub-loop) state_enter events."""
        events = [
            {"event": "state_enter", "state": "child_state", "iteration": 1, "depth": 1},
        ]
        executor = MockExecutor(events)
        with patch("sys.stdout.isatty", return_value=True):
            run_foreground(executor, self._make_fsm(), self._make_args(clear=True))
        out = capsys.readouterr().out
        assert "\033[2J" not in out

    def test_sub_loop_diagram_keeps_parent_state_highlighted(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """During sub-loop execution, parent FSM diagram keeps the sub-loop state highlighted.

        When depth=1 child events arrive, _render_fsm_diagram should be called with
        the last depth=0 state as highlight_state, not the child state.
        """
        from unittest.mock import patch

        from little_loops.cli.loop import layout as layout_mod

        fsm = make_test_fsm(
            initial="run_sub_loop",
            states={
                "run_sub_loop": make_test_state(action="echo sub", on_yes="done"),
                "done": make_test_state(terminal=True),
            },
        )
        events = [
            {"event": "state_enter", "state": "run_sub_loop", "iteration": 1},
            {"event": "state_enter", "state": "child_state_1", "iteration": 1, "depth": 1},
            {"event": "state_enter", "state": "child_state_2", "iteration": 2, "depth": 1},
        ]
        executor = MockExecutor(events)
        with patch.object(
            layout_mod, "_render_fsm_diagram", wraps=layout_mod._render_fsm_diagram
        ) as mock_render:
            run_foreground(executor, fsm, self._make_args(show_diagrams=True))
            assert mock_render.call_count == 3
            for call_args in mock_render.call_args_list:
                assert call_args.kwargs["highlight_state"] == "run_sub_loop", (
                    f"Expected highlight_state='run_sub_loop', got {call_args.kwargs['highlight_state']!r}"
                )

    def test_sub_loop_state_enter_indented_with_depth(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """state_enter event with depth=1 is prefixed with 2-space indent."""
        events = [
            {"event": "state_enter", "state": "child_state", "iteration": 1, "depth": 1},
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args())
        out = capsys.readouterr().out
        state_lines = [ln for ln in out.splitlines() if "child_state" in ln]
        assert state_lines, "Expected state_enter output for child_state"
        assert state_lines[0].startswith("  "), f"Expected 2-space indent, got: {state_lines[0]!r}"

    def test_depth_zero_state_enter_not_indented(self, capsys: pytest.CaptureFixture[str]) -> None:
        """state_enter event with no depth (depth=0) is not indented."""
        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args())
        out = capsys.readouterr().out
        state_lines = [ln for ln in out.splitlines() if "start" in ln and "[" in ln]
        assert state_lines, "Expected state_enter output for start"
        assert not state_lines[0].startswith("  "), "Depth-0 output should not be indented"

    def test_sub_loop_child_diagram_rendered_during_sub_loop_execution(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """During sub-loop execution, both parent and child FSM diagrams are rendered.

        When a depth=1 state_enter event arrives and the parent state had loop set,
        _render_fsm_diagram is called for parent (highlight=parent_state) then for
        child (highlight=child_state). A separator line is printed between them.
        """
        from unittest.mock import call, patch

        from little_loops.cli.loop import layout as layout_mod

        child_fsm = make_test_fsm(
            name="child-loop",
            initial="child_state_1",
            states={
                "child_state_1": make_test_state(action="echo child", next="done"),
                "done": make_test_state(terminal=True),
            },
        )
        parent_fsm = make_test_fsm(
            name="parent-loop",
            initial="run_sub_loop",
            states={
                "run_sub_loop": StateConfig(loop="child-loop", next="done"),
                "done": make_test_state(terminal=True),
            },
        )
        events = [
            {"event": "state_enter", "state": "run_sub_loop", "iteration": 1},
            {"event": "state_enter", "state": "child_state_1", "iteration": 1, "depth": 1},
        ]
        executor = MockExecutor(events)
        with (
            patch.object(
                layout_mod, "_render_fsm_diagram", wraps=layout_mod._render_fsm_diagram
            ) as mock_render,
            patch("little_loops.cli.loop._helpers.load_loop", return_value=child_fsm),
        ):
            run_foreground(executor, parent_fsm, self._make_args(show_diagrams=True))

        # depth=0 event: 1 render (parent only, child FSM loaded but not yet shown)
        # depth=1 event: 2 renders (parent with parent highlight + child with child highlight)
        assert mock_render.call_count == 3, f"Expected 3 render calls, got {mock_render.call_count}"
        calls = mock_render.call_args_list
        assert calls[0] == call(
            parent_fsm, highlight_state="run_sub_loop", highlight_color="32", edge_label_colors=None
        )
        assert calls[1] == call(
            parent_fsm, highlight_state="run_sub_loop", highlight_color="32", edge_label_colors=None
        )
        assert calls[2] == call(
            child_fsm, highlight_state="child_state_1", highlight_color="32", edge_label_colors=None
        )
        out = capsys.readouterr().out
        assert "sub-loop: child-loop" in out

    def test_top_level_loop_header_shown_when_show_diagrams(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Top-level loop header is printed before the FSM diagram when show_diagrams=True."""
        events = [
            {"event": "state_enter", "state": "start", "iteration": 1},
        ]
        executor = MockExecutor(events)
        fsm = make_test_fsm(name="my-loop")
        run_foreground(executor, fsm, self._make_args(show_diagrams=True))
        out = capsys.readouterr().out
        assert "== loop: my-loop" in out

    def test_sub_loop_route_indented_with_depth(self, capsys: pytest.CaptureFixture[str]) -> None:
        """route event with depth=1 is prefixed with 2-space indent."""
        events = [
            {"event": "route", "from": "a", "to": "b", "depth": 1},
        ]
        executor = MockExecutor(events)
        run_foreground(executor, self._make_fsm(), self._make_args())
        out = capsys.readouterr().out
        route_lines = [ln for ln in out.splitlines() if "->" in ln and "b" in ln]
        assert route_lines, "Expected route output"
        assert route_lines[0].startswith("  "), f"Expected 2-space indent, got: {route_lines[0]!r}"


class TestRunForegroundExitCodes:
    """Tests for run_foreground exit code mapping (BUG-605)."""

    def _make_args(self) -> argparse.Namespace:
        return argparse.Namespace(quiet=False, verbose=False, show_diagrams=False, clear=False)

    def _make_fsm(self) -> FSMLoop:
        return make_test_fsm()

    def _run_with_terminated_by(self, terminated_by: str) -> int:
        """Run run_foreground with a mock executor returning given terminated_by."""

        class _Executor:
            def __init__(self, tb: str) -> None:
                self._tb = tb
                self._on_event: Any = None

            def run(self) -> ExecutionResult:
                return ExecutionResult(
                    final_state="done",
                    iterations=1,
                    terminated_by=self._tb,
                    duration_ms=100,
                    captured={},
                )

        with patch("builtins.print"):
            return run_foreground(_Executor(terminated_by), self._make_fsm(), self._make_args())

    @pytest.mark.parametrize("terminated_by", ["terminal", "signal", "handoff"])
    def test_zero_exit_code_for_graceful_termination(self, terminated_by: str) -> None:
        """terminal, signal, and handoff all return exit code 0."""
        assert self._run_with_terminated_by(terminated_by) == 0

    @pytest.mark.parametrize("terminated_by", ["max_iterations", "timeout"])
    def test_nonzero_exit_code_for_limit_termination(self, terminated_by: str) -> None:
        """max_iterations and timeout return exit code 1."""
        assert self._run_with_terminated_by(terminated_by) == 1

    def test_unknown_terminated_by_returns_1(self) -> None:
        """Unknown terminated_by values fall back to exit code 1."""
        assert self._run_with_terminated_by("unexpected_value") == 1

    def test_exit_codes_dict_matches_expected_mapping(self) -> None:
        """EXIT_CODES dict has the expected keys and values."""
        assert EXIT_CODES["terminal"] == 0
        assert EXIT_CODES["signal"] == 0
        assert EXIT_CODES["handoff"] == 0
        assert EXIT_CODES["max_iterations"] == 1
        assert EXIT_CODES["timeout"] == 1


class TestStateBadges:
    """Tests for unicode badge constants and _get_state_badge helper."""

    def test_badge_constants_match_spec(self) -> None:
        """Badge strings match the spec values."""
        assert _ACTION_TYPE_BADGES["prompt"] == "\u2726"  # ✦
        assert _ACTION_TYPE_BADGES["slash_command"] == "/\u2501\u25ba"  # /━►
        assert _ACTION_TYPE_BADGES["shell"] == "\u276f_"  # ❯_
        assert _ACTION_TYPE_BADGES["mcp_tool"] == "\u26a1"  # ⚡
        assert _SUB_LOOP_BADGE == "\u21b3\u27f3"  # ↳⟳

    def test_get_state_badge_none_state(self) -> None:
        """No state → empty badge."""
        assert _get_state_badge(None) == ""

    def test_get_state_badge_no_action(self) -> None:
        """State with no action or action_type → empty badge."""
        assert _get_state_badge(StateConfig()) == ""

    def test_get_state_badge_action_types(self) -> None:
        """Known action_types map to their unicode badges."""
        for action_type, expected in _ACTION_TYPE_BADGES.items():
            state = StateConfig(action="x", action_type=action_type)  # type: ignore[arg-type]
            assert _get_state_badge(state) == expected, f"action_type={action_type!r}"

    def test_get_state_badge_shell_fallback(self) -> None:
        """State with action but no action_type falls back to shell badge."""
        state = StateConfig(action="echo hi")
        assert _get_state_badge(state) == _ACTION_TYPE_BADGES["shell"]

    def test_get_state_badge_sub_loop(self) -> None:
        """State with loop field returns sub-loop badge regardless of action_type."""
        state = StateConfig(loop="child-loop.yaml")
        assert _get_state_badge(state) == _SUB_LOOP_BADGE

    def test_sub_loop_badge_takes_precedence_over_action_type(self) -> None:
        """loop field checked before action_type for badge selection."""
        state = StateConfig(action_type="prompt", loop="child.yaml")  # type: ignore[arg-type]
        assert _get_state_badge(state) == _SUB_LOOP_BADGE

    def test_diagram_contains_prompt_badge(self) -> None:
        """FSM diagram output contains ✦ in top border with space padding on each side."""
        fsm = FSMLoop(
            name="test",
            initial="start",
            states={
                "start": StateConfig(action="do something", action_type="prompt", next="done"),
                "done": StateConfig(terminal=True),
            },
            max_iterations=5,
        )
        result = _render_fsm_diagram(fsm)
        assert "\u2726" in result  # ✦ prompt badge present
        top_border = next(ln for ln in result.split("\n") if "\u250c" in ln)
        assert " \u2726 " in top_border  # space padding on each side

    def test_badge_border_width_accounts_for_padding(self) -> None:
        """Box width is large enough for badge + 2 padding spaces even when badge > label."""
        # Use a sub-loop badge (↳⟳, display width 2) on a state with a very short name
        # so badge_w + 2 > len(label). The box must still render without truncating the badge.
        fsm = FSMLoop(
            name="test",
            initial="x",
            states={
                "x": StateConfig(loop="child.yaml", next="done"),
                "done": StateConfig(terminal=True),
            },
            max_iterations=1,
        )
        result = _render_fsm_diagram(fsm)
        top_border = next(
            ln for ln in result.split("\n") if "\u250c" in ln and _SUB_LOOP_BADGE[0] in ln
        )
        # Both padding spaces must appear alongside the badge
        assert " " + _SUB_LOOP_BADGE[0] in top_border  # leading space before badge

    def test_highlighted_badge_is_colorized(self) -> None:
        """Badge characters in top border use _bc() colorization when state is highlighted."""
        import little_loops.cli.output as output_mod

        fsm = FSMLoop(
            name="test",
            initial="start",
            states={
                "start": StateConfig(action="do something", action_type="prompt", next="done"),
                "done": StateConfig(terminal=True),
            },
            max_iterations=5,
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm, highlight_state="start", highlight_color="36")
        # The badge ✦ must appear wrapped in the highlight color
        assert "\033[36m\u2726" in result

    def test_route_badge_constant(self) -> None:
        """_ROUTE_BADGE is the dedicated branching/routing unicode character."""
        assert _ROUTE_BADGE == "\u2443"  # ⑃

    def test_get_state_badge_route_state(self) -> None:
        """State with route field returns the route badge."""
        state = StateConfig(route=RouteConfig(routes={"yes": "a", "no": "b"}))
        assert _get_state_badge(state) == _ROUTE_BADGE

    def test_route_badge_lower_priority_than_sub_loop(self) -> None:
        """loop field takes precedence over route for badge selection."""
        state = StateConfig(
            loop="child.yaml",
            route=RouteConfig(routes={"yes": "a"}),
        )
        assert _get_state_badge(state) == _SUB_LOOP_BADGE

    def test_diagram_contains_route_badge(self) -> None:
        """FSM diagram output contains ⑃ for a route state."""
        fsm = FSMLoop(
            name="test",
            initial="route_format",
            states={
                "route_format": StateConfig(
                    route=RouteConfig(routes={"pass": "done", "fail": "done"})
                ),
                "done": StateConfig(terminal=True),
            },
            max_iterations=5,
        )
        result = _render_fsm_diagram(fsm)
        assert "\u2443" in result  # ⑃ route badge in top border


class TestEdgeLineColorization:
    """Tests that FSM transition line characters are colored by edge semantic type.

    ENH-813: Color-code transition lines (│ pipes, ─ dashes, corner connectors,
    ▼▶ arrowheads) in addition to the existing label text colorization.
    """

    def _make_fsm(
        self,
        initial: str = "a",
        states: dict[str, StateConfig] | None = None,
    ) -> FSMLoop:
        return FSMLoop(name="test", initial=initial, states=states or {}, max_iterations=10)

    def test_collect_edges_includes_on_blocked(self) -> None:
        """_collect_edges() collects on_blocked transitions as 'blocked' label."""
        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step", on_blocked="b"),
                "b": StateConfig(terminal=True),
            }
        )
        edges = _collect_edges(fsm)
        assert ("a", "b", "blocked") in edges

    def test_collect_edges_includes_on_retry_exhausted(self) -> None:
        """_collect_edges() collects on_retry_exhausted transitions."""
        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step", max_retries=3, on_retry_exhausted="b"),
                "b": StateConfig(terminal=True),
            }
        )
        edges = _collect_edges(fsm)
        assert ("a", "b", "retry_exhausted") in edges

    def test_error_edge_connector_chars_are_colored_red(self) -> None:
        """on_error transition connector characters (│, ▼) are colored red."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step", on_error="b"),
                "b": StateConfig(terminal=True),
            }
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm)

        # The pipe │ or arrowhead ▼ should be wrapped in red ANSI (code 31)
        assert "\033[31m\u2502" in result or "\033[31m\u25bc" in result, (
            f"Expected red ANSI on error edge connector chars.\n{result!r}"
        )

    def test_yes_edge_connector_chars_are_colored_green(self) -> None:
        """on_yes transition connector characters are colored green."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step", on_yes="b"),
                "b": StateConfig(terminal=True),
            }
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm)

        # Green code 32 on │ or ▼
        assert "\033[32m\u2502" in result or "\033[32m\u25bc" in result, (
            f"Expected green ANSI on yes edge connector chars.\n{result!r}"
        )

    def test_no_edge_connector_chars_are_colored_orange(self) -> None:
        """on_no transition connector characters are colored orange."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step", on_no="b"),
                "b": StateConfig(terminal=True),
            }
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm)

        # Orange code 38;5;208 on │ or ▼
        assert "\033[38;5;208m\u2502" in result or "\033[38;5;208m\u25bc" in result, (
            f"Expected orange ANSI on no edge connector chars.\n{result!r}"
        )

    def test_blocked_edge_connector_chars_are_colored_red(self) -> None:
        """on_blocked transition connector characters are colored red."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step", on_blocked="b"),
                "b": StateConfig(terminal=True),
            }
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm)

        assert "\033[31m\u2502" in result or "\033[31m\u25bc" in result, (
            f"Expected red ANSI on blocked edge connector chars.\n{result!r}"
        )

    def test_back_edge_connector_chars_are_colored(self) -> None:
        """Back-edge (loop-back) connector characters are colored by edge type."""
        import little_loops.cli.output as output_mod

        # a → b (on_yes), b → a (on_error, back-edge)
        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step_a", on_yes="b"),
                "b": StateConfig(action="step_b", on_error="a", on_yes="c"),
                "c": StateConfig(terminal=True),
            }
        )
        with patch.object(output_mod, "_USE_COLOR", True):
            result = _render_fsm_diagram(fsm)

        # Back-edge uses │ (vertical) and ▶ (arrowhead): expect red on error back-edge
        assert "\033[31m\u2502" in result or "\033[31m\u25b6" in result, (
            f"Expected red ANSI on error back-edge connector chars.\n{result!r}"
        )

    def test_no_ansi_on_connector_chars_when_color_disabled(self) -> None:
        """When _USE_COLOR is False, no ANSI codes appear on connector characters."""
        import little_loops.cli.output as output_mod

        fsm = self._make_fsm(
            states={
                "a": StateConfig(action="step", on_error="b"),
                "b": StateConfig(terminal=True),
            }
        )
        with patch.object(output_mod, "_USE_COLOR", False):
            result = _render_fsm_diagram(fsm)

        assert "\033[" not in result, f"Expected no ANSI codes when color disabled.\n{result!r}"
