"""Bridge 명령 라우팅 테스트.""" from __future__ import annotations import sys import types slack_bolt = types.ModuleType("slack_bolt") slack_bolt.App = object socket_mode = types.ModuleType("slack_bolt.adapter.socket_mode") socket_mode.SocketModeHandler = object slack_sdk = types.ModuleType("slack_sdk") slack_sdk.WebClient = object sys.modules.setdefault("slack_bolt", slack_bolt) sys.modules.setdefault("slack_bolt.adapter.socket_mode", socket_mode) sys.modules.setdefault("slack_sdk", slack_sdk) class FakeSlackHandler: def __init__(self, _config) -> None: self.message_callback = None self.command_callback = None self.sent_messages: list[tuple[str, str]] = [] def on_message(self, callback) -> None: self.message_callback = callback def on_command(self, callback) -> None: self.command_callback = callback def send_message(self, channel: str, text: str) -> None: self.sent_messages.append((channel, text)) def start(self) -> None: return None def stop(self) -> None: return None class FakePtyManager: instances: list[FakePtyManager] = [] def __init__(self, session_name: str, cli_name: str = "claude") -> None: self.session_name = session_name self.cli_name = cli_name self._alive = False self.sent_inputs: list[str] = [] self.enter_count = 0 FakePtyManager.instances.append(self) @property def is_alive(self) -> bool: return self._alive def start(self) -> None: self._alive = True def stop(self) -> None: self._alive = False def send(self, text: str) -> None: self.sent_inputs.append(text) def send_enter(self) -> None: self.enter_count += 1 def read_output(self, timeout: float = 5) -> str: return "" def _make_bridge(monkeypatch): from lazy_enter.bridge import Bridge from lazy_enter.config import Config monkeypatch.setattr("lazy_enter.bridge.SlackHandler", FakeSlackHandler) monkeypatch.setattr("lazy_enter.bridge.PtyManager", FakePtyManager) config = Config() config.tmux_session_name = "claude-room" config.codex_tmux_session_name = "codex-room" return Bridge(config) def test_start_claude_routes_to_claude_session(monkeypatch) -> None: FakePtyManager.instances.clear() bridge = _make_bridge(monkeypatch) bridge._handle_command("start", "claude", "C1") assert FakePtyManager.instances[-1].session_name == "claude-room" assert FakePtyManager.instances[-1].cli_name == "claude" assert bridge.slack.sent_messages[-1] == ( "C1", ":link: Claude 세션에 연결되었습니다.", ) def test_start_codex_routes_to_codex_session(monkeypatch) -> None: FakePtyManager.instances.clear() bridge = _make_bridge(monkeypatch) bridge._handle_command("start", "codex", "C1") assert FakePtyManager.instances[-1].session_name == "codex-room" assert FakePtyManager.instances[-1].cli_name == "codex" assert bridge.slack.sent_messages[-1] == ( "C1", ":link: Codex 세션에 연결되었습니다.", ) def test_start_session_resets_output_dedup_state(monkeypatch) -> None: FakePtyManager.instances.clear() bridge = _make_bridge(monkeypatch) bridge._last_sent_output = "stale" bridge._last_sent_fingerprint = "stale-fp" bridge._handle_command("start", "codex", "C1") assert bridge._last_sent_output == "" assert bridge._last_sent_fingerprint is None def test_unknown_target_is_rejected(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._handle_command("start", "unknown", "C1") assert bridge.slack.sent_messages[-1] == ( "C1", ":warning: 지원하지 않는 대상입니다.", ) def test_handle_message_resets_last_sent_output_after_input(monkeypatch) -> None: FakePtyManager.instances.clear() bridge = _make_bridge(monkeypatch) bridge._handle_command("start", "codex", "C1") pty = FakePtyManager.instances[-1] bridge._last_sent_output = "previous output" bridge._handle_message("status", "C1") assert pty.sent_inputs[-1] == "status" assert bridge._last_sent_output == "" assert bridge._last_sent_fingerprint is None def test_handle_message_enter_command_sends_enter_only(monkeypatch) -> None: FakePtyManager.instances.clear() bridge = _make_bridge(monkeypatch) bridge._handle_command("start", "codex", "C1") pty = FakePtyManager.instances[-1] bridge._handle_message("!enter", "C1") assert pty.sent_inputs == [] assert pty.enter_count == 1 def test_handle_message_short_enter_alias_sends_enter_only(monkeypatch) -> None: FakePtyManager.instances.clear() bridge = _make_bridge(monkeypatch) bridge._handle_command("start", "codex", "C1") pty = FakePtyManager.instances[-1] bridge._handle_message("!e", "C1") assert pty.sent_inputs == [] assert pty.enter_count == 1 def test_handle_message_bang_is_plain_input(monkeypatch) -> None: FakePtyManager.instances.clear() bridge = _make_bridge(monkeypatch) bridge._handle_command("start", "codex", "C1") pty = FakePtyManager.instances[-1] bridge._handle_message("!", "C1") assert pty.sent_inputs == ["!"] assert pty.enter_count == 0 def test_split_message_preserves_all_content(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) chunks = bridge._split_message("line1\nline2\nline3", max_length=7) assert "".join(chunks) == "line1\nline2\nline3" def test_split_message_preserves_trailing_whitespace_and_blank_line( monkeypatch, ) -> None: bridge = _make_bridge(monkeypatch) text = "ab \n\ncd " chunks = bridge._split_message(text, max_length=4) assert "".join(chunks) == text def test_send_output_chunks_sends_multiple_messages(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge._send_output_chunks("1234567890") assert bridge.slack.sent_messages == [ ("C1", "```\n1234567890\n```"), ] bridge.config.max_message_length = 4 bridge.slack.sent_messages.clear() bridge._send_output_chunks("abcdefghij") assert bridge.slack.sent_messages == [ ("C1", "```\nabcd\n```"), ("C1", "```\nefgh\n```"), ("C1", "```\nij\n```"), ] def test_send_output_chunks_skips_duplicate_snapshot(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.max_message_length = 4 bridge._send_output_chunks("abcdefghij") first = list(bridge.slack.sent_messages) bridge._send_output_chunks("abcdefghij") assert bridge.slack.sent_messages == first def test_send_output_chunks_skips_duplicate_with_volatile_status_line( monkeypatch, ) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.max_message_length = 1000 first_output = ( "Would you like to make the following edits?\n" "src/main.py (+68 -1)\n" 'odex] 0:node* [0,0] "jihoson-home" 05:12 17-Feb-26\n' "Press enter to confirm or esc to cancel\n" ) second_output = ( "Would you like to make the following edits?\n" "src/main.py (+68 -1)\n" 'odex] 0:node* [0,0] "jihoson-home" 05:13 17-Feb-26\n' "Press enter to confirm or esc to cancel\n" ) bridge._send_output_chunks(first_output) first = list(bridge.slack.sent_messages) bridge._send_output_chunks(second_output) assert bridge.slack.sent_messages == first def test_send_output_chunks_sends_first_when_fingerprint_is_empty(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" volatile_only = 'odex] 0:node* [0,0] "jihoson-home" 05:12 17-Feb-26\n' bridge._send_output_chunks(volatile_only) assert bridge.slack.sent_messages == [("C1", f"```\n{volatile_only.rstrip()}\n```")] bridge._send_output_chunks(volatile_only) assert bridge.slack.sent_messages == [("C1", f"```\n{volatile_only.rstrip()}\n```")] def test_send_output_chunks_keeps_non_tmux_timestamp_lines(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.max_message_length = 1000 first_output = ( "report generated at 05:12 17-Feb-26\n" "count=10\n" ) second_output = ( "report generated at 05:13 17-Feb-26\n" "count=10\n" ) bridge._send_output_chunks(first_output) bridge._send_output_chunks(second_output) assert bridge.slack.sent_messages == [ ("C1", f"```\n{first_output.rstrip()}\n```"), ("C1", f"```\n{second_output.rstrip()}\n```"), ] def test_send_output_chunks_preserves_whitespace_signals(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.max_message_length = 1000 first_output = "def f():\n return 1\n" second_output = "def f():\n return 1\n" bridge._send_output_chunks(first_output) bridge._send_output_chunks(second_output) assert bridge.slack.sent_messages == [ ("C1", f"```\n{first_output.rstrip()}\n```"), ("C1", f"```\n{second_output.rstrip()}\n```"), ] def test_send_output_chunks_keeps_standalone_time_lines(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.max_message_length = 1000 bridge._send_output_chunks("[5:12 AM]\n") bridge._send_output_chunks("[5:13 AM]\n") assert bridge.slack.sent_messages == [ ("C1", "```\n[5:12 AM]\n```"), ("C1", "```\n[5:13 AM]\n```"), ] def test_send_output_chunks_keeps_non_tmux_status_like_lines(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.max_message_length = 1000 first_output = '[2,3] "job-runner" 05:12 17-Feb-26\n' second_output = '[2,3] "job-runner" 05:13 17-Feb-26\n' bridge._send_output_chunks(first_output) bridge._send_output_chunks(second_output) assert bridge.slack.sent_messages == [ ("C1", "```\n[2,3] \"job-runner\" 05:12 17-Feb-26\n```"), ("C1", "```\n[2,3] \"job-runner\" 05:13 17-Feb-26\n```"), ] def test_should_flush_output_buffer_when_settled(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge.config.output_settle_seconds = 4.0 bridge.config.output_flush_interval_seconds = 15.0 bridge._last_output_at = 10.0 bridge._output_buffer_started_at = 2.0 assert bridge._should_flush_output_buffer(14.1) is True def test_should_flush_output_buffer_when_flush_interval_elapsed(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge.config.output_settle_seconds = 4.0 bridge.config.output_flush_interval_seconds = 15.0 bridge._last_output_at = 20.0 bridge._output_buffer_started_at = 2.0 assert bridge._should_flush_output_buffer(17.1) is True def test_should_flush_output_buffer_false_during_active_stream(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge.config.output_settle_seconds = 4.0 bridge.config.output_flush_interval_seconds = 15.0 bridge._last_output_at = 19.0 bridge._output_buffer_started_at = 10.0 assert bridge._should_flush_output_buffer(20.0) is False def test_poll_output_skips_final_flush_after_intentional_stop(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.output_settle_seconds = 9999.0 bridge.config.output_flush_interval_seconds = 9999.0 bridge.config.output_buffer_interval = 0.0 pty = FakePtyManager("codex-room", cli_name="codex") pty._alive = True bridge.pty = pty bridge._running = True sent_buffers: list[str] = [] monkeypatch.setattr(bridge, "_send_output_chunks", sent_buffers.append) def _read_output(timeout: float = 5) -> str: bridge._running = False return "planning update" monkeypatch.setattr(pty, "read_output", _read_output) bridge._poll_output() assert sent_buffers == [] def test_next_read_timeout_is_capped_by_flush_deadline(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge.config.pty_read_timeout = 5 bridge.config.output_settle_seconds = 4.0 bridge.config.output_flush_interval_seconds = 15.0 bridge._last_output_at = 100.0 bridge._output_buffer_started_at = 95.0 timeout = bridge._next_read_timeout(103.6, has_buffer=True) assert 0.39 <= timeout <= 0.41 def test_poll_output_uses_shorter_timeout_near_settle_deadline(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) bridge._channel = "C1" bridge.config.pty_read_timeout = 5 bridge.config.output_settle_seconds = 4.0 bridge.config.output_flush_interval_seconds = 15.0 bridge.config.output_buffer_interval = 0.0 bridge.config.output_idle_report_seconds = 0 bridge.config.input_idle_report_seconds = 0 pty = FakePtyManager("codex-room", cli_name="codex") pty._alive = True bridge.pty = pty bridge._running = True observed_timeouts: list[float] = [] call_count = 0 def _read_output(timeout: float = 5) -> str: nonlocal call_count observed_timeouts.append(timeout) if call_count == 0: call_count += 1 return "first chunk" bridge._running = False return "" monkeypatch.setattr(pty, "read_output", _read_output) bridge._poll_output() assert len(observed_timeouts) == 2 assert observed_timeouts[0] == 5 assert 0.0 <= observed_timeouts[1] < 5