diff --git a/.github/scripts/check_package.py b/.github/scripts/check_package.py new file mode 100644 index 0000000..4e47746 --- /dev/null +++ b/.github/scripts/check_package.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Static checks that need no Unity installation. + +Two kinds of drift can land here without anyone noticing. The README action +tables can fall behind the `[CommandAction]` declarations, which is what +happened before #12 added ten missing rows. And a tracked file can arrive +without its `.meta`, which only shows up when someone imports the package. + +Run from anywhere: + + python .github/scripts/check_package.py +""" + +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# The first two positional arguments are the namespace and the action. The +# attribute is written on one line in a few places and spread over five in +# most, so the pattern has to tolerate newlines between them. +COMMAND_ACTION = re.compile(r'\[CommandAction\(\s*"([^"]+)"\s*,\s*"([^"]+)"') + +READMES = ("README.md", "README_zh.md") + +SEPARATOR_CELL = re.compile(r"^:?-+:?$") + + +def tracked_files(): + result = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + return [path for path in result.stdout.split("\0") if path] + + +def split_row(line): + """Cells of a Markdown table row, or None if the line is not one.""" + stripped = line.strip() + if not stripped.startswith("|"): + return None + return [cell.strip() for cell in stripped.strip("|").split("|")] + + +def declared_actions(files): + """Every `/` the package registers, in declaration order.""" + found = [] + for relative in files: + if not relative.endswith(".cs"): + continue + text = (REPO_ROOT / relative).read_text("utf-8") + for namespace, action in COMMAND_ACTION.findall(text): + found.append((f"{namespace}/{action}", relative)) + return found + + +def documented_actions(relative): + """The action table of one README: its ids and the sentence counting them.""" + lines = (REPO_ROOT / relative).read_text("utf-8").splitlines() + + headers = [ + index + for index, line in enumerate(lines) + if (cells := split_row(line)) and len(cells) >= 3 and cells[1] == "Action" + ] + if len(headers) != 1: + raise LookupError( + f"{relative}: expected exactly one table with an 'Action' column, " + f"found {len(headers)}" + ) + header = headers[0] + + ids, namespace = [], "" + for line in lines[header + 1 :]: + cells = split_row(line) + if cells is None: + break + if all(SEPARATOR_CELL.match(cell) for cell in cells if cell): + continue + if cells[0]: + namespace = cells[0].strip("*") + ids.append(f"{namespace}/{cells[1].strip('`')}") + + # The paragraph directly above the table states how many actions and + # namespaces there are. Both READMEs word it differently, so match on the + # numbers rather than the prose. + summary = next( + (line.strip() for line in reversed(lines[:header]) if line.strip()), "" + ) + return ids, summary + + +def check_action_tables(files): + declared = declared_actions(files) + ids = [action for action, _ in declared] + problems = [] + + duplicates = sorted({action for action in ids if ids.count(action) > 1}) + if duplicates: + problems.append(f"declared twice in C#: {', '.join(duplicates)}") + + expected = set(ids) + namespaces = {action.split("/", 1)[0] for action in expected} + + for relative in READMES: + documented, summary = documented_actions(relative) + missing = sorted(expected - set(documented)) + extra = sorted(set(documented) - expected) + if missing: + problems.append(f"{relative}: declared but not in the table: {', '.join(missing)}") + if extra: + problems.append(f"{relative}: in the table but not declared: {', '.join(extra)}") + + counts = set(re.findall(r"\d+", summary)) + if str(len(expected)) not in counts or str(len(namespaces)) not in counts: + problems.append( + f"{relative}: the sentence above the table reads {summary!r}, " + f"but there are {len(expected)} actions in {len(namespaces)} namespaces" + ) + + return f"{len(expected)} actions in {len(namespaces)} namespaces", problems + + +def unity_visible(relative): + """Whether Unity's importer looks at this path at all.""" + for part in PurePosixPath(relative).parts: + if part.startswith(".") or part.endswith("~") or part.endswith(".tmp"): + return False + if part.lower() == "cvs": + return False + return True + + +def check_meta_files(files): + tracked = set(files) + directories = set() + for path in files: + parent = PurePosixPath(path).parent + while str(parent) != ".": + directories.add(str(parent)) + parent = parent.parent + + assets = [ + path + for path in sorted(tracked | directories) + if not path.endswith(".meta") and unity_visible(path) + ] + missing = [path for path in assets if path + ".meta" not in tracked] + orphans = [ + path + for path in sorted(tracked) + if path.endswith(".meta") + and path[:-5] not in tracked + and path[:-5] not in directories + ] + + problems = [] + if missing: + problems.append("no .meta for: " + ", ".join(missing)) + if orphans: + problems.append(".meta with nothing behind it: " + ", ".join(orphans)) + return f"{len(assets)} imported paths", problems + + +def main(): + files = tracked_files() + failed = False + + for name, check in (("action tables", check_action_tables), ("meta files", check_meta_files)): + try: + detail, problems = check(files) + except LookupError as error: + print(f"FAIL {name}\n {error}") + failed = True + continue + if problems: + failed = True + print(f"FAIL {name}") + for problem in problems: + print(f" {problem}") + else: + print(f"ok {name} ({detail})") + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..abfce5d --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,32 @@ +name: Checks + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run the Unity-free static checks + run: python -B .github/scripts/check_package.py + + - name: Install the REPL dependencies + run: pip install -r "Editor/ExternalTool~/console-client/requirements-repl.txt" + + - name: Run the shared core suite + run: python -B -m unittest discover -s "Editor/ExternalTool~/console-client/tests/csharpconsole_core" -p "test_*.py" -v + + - name: Run the REPL suite + run: python -B -m unittest discover -s "Editor/ExternalTool~/console-client/tests/tests_repl" -p "test_*.py" -v diff --git a/Editor/ExternalTool~/console-client/.gitignore b/Editor/ExternalTool~/console-client/.gitignore index f7033e3..c2c0e63 100644 --- a/Editor/ExternalTool~/console-client/.gitignore +++ b/Editor/ExternalTool~/console-client/.gitignore @@ -2,4 +2,3 @@ site-packages/ .cache/ __pycache__/ *.pyc -tests/ diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/_bootstrap.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/_bootstrap.py new file mode 100644 index 0000000..031b585 --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/_bootstrap.py @@ -0,0 +1,8 @@ +import os +import sys + +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +CORE_TESTS_ROOT = os.path.dirname(TESTS_DIR) +CONSOLE_CLIENT_ROOT = os.path.dirname(CORE_TESTS_ROOT) +if CONSOLE_CLIENT_ROOT not in sys.path: + sys.path.insert(0, CONSOLE_CLIENT_ROOT) diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_client_base.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_client_base.py new file mode 100644 index 0000000..7c5a03b --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_client_base.py @@ -0,0 +1,67 @@ +import unittest + +import _bootstrap # noqa: F401 +from csharpconsole_core.client_base import generate_session_id, read_code_from_args, wait_for_service_recovery + + +class ClientBaseTests(unittest.TestCase): + def test_generate_session_id_uses_explicit_value(self): + self.assertEqual(generate_session_id('sid-1'), 'sid-1') + + def test_read_code_from_args_uses_inline_code(self): + class Args: + code = 'Debug.Log(1);' + code_file = None + self.assertEqual(read_code_from_args(Args()), 'Debug.Log(1);') + + def test_wait_for_service_recovery_returns_when_health_ready(self): + def request_health(): + return { + 'ok': True, + 'data': { + 'initialized': True, + 'editorState': 'ready', + 'operation': {'phase': 'ready'}, + }, + 'summary': 'ok', + } + + def current_mode_name(): + return 'editor' + + result = wait_for_service_recovery(request_health, current_mode_name, 1, poll_interval_seconds=0.01) + self.assertTrue(result['ok']) + self.assertEqual(result['summary'], 'Unity service recovered after refresh') + + def test_wait_for_service_recovery_returns_failed_phase(self): + def request_health(): + return { + 'ok': True, + 'data': { + 'operation': {'phase': 'failed', 'message': 'bad'}, + 'editorState': 'compiling', + }, + 'summary': 'bad', + } + + def current_mode_name(): + return 'editor' + + result = wait_for_service_recovery(request_health, current_mode_name, 1, poll_interval_seconds=0.01) + self.assertFalse(result['ok']) + self.assertEqual(result['summary'], 'bad') + + def test_wait_for_service_recovery_times_out(self): + def request_health(): + return {'ok': False, 'summary': 'still waiting'} + + def current_mode_name(): + return 'editor' + + result = wait_for_service_recovery(request_health, current_mode_name, 0.02, poll_interval_seconds=0.01) + self.assertFalse(result['ok']) + self.assertIn('Timed out waiting for Unity service recovery', result['summary']) + + +if __name__ == '__main__': + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_command_protocol.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_command_protocol.py new file mode 100644 index 0000000..293246e --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_command_protocol.py @@ -0,0 +1,123 @@ +import json +import unittest +from pathlib import Path + +import _bootstrap # noqa: F401 +from csharpconsole_core.command_protocol import _coerce_args_json, request_command +from csharpconsole_core.transport_http import TransportError + + +REPO_ROOT = Path(__file__).resolve().parents[5] + + +class CommandProtocolTests(unittest.TestCase): + def test_coerce_args_json_from_single_json_string(self): + payload = _coerce_args_json(["{\"foo\":1}"]) + self.assertEqual(json.loads(payload), {"foo": 1}) + + def test_coerce_args_json_wraps_plain_string(self): + payload = _coerce_args_json("hello") + self.assertEqual(json.loads(payload), {"value": "hello"}) + + def test_coerce_args_json_accepts_dict(self): + payload = _coerce_args_json({"foo": 1}) + self.assertEqual(json.loads(payload), {"foo": 1}) + + def test_request_command_sends_structured_invocation_payload(self): + captured = {} + + def post_json_func(endpoint, payload, timeout_seconds): + captured["endpoint"] = endpoint + captured["payload"] = payload + return json.dumps({ + "ok": True, + "stage": "command", + "type": "ok", + "summary": "listed", + "sessionId": "sid-1", + "dataJson": json.dumps({ + "command": { + "commandNamespace": "session", + "action": "list", + }, + "resultJson": {"items": []}, + }), + }) + + def current_mode_name(): + return "editor" + + from csharpconsole_core.response_parser import parse_command_http_response + + result = request_command( + post_json_func, + parse_command_http_response, + current_mode_name, + "session", + "list", + session_id="sid-1", + raw_args={"all": True}, + ) + + self.assertTrue(result["ok"]) + self.assertEqual(captured["endpoint"], "command") + self.assertEqual(sorted(captured["payload"].keys()), ["invocation"]) + self.assertEqual(captured["payload"]["invocation"]["command"]["commandNamespace"], "session") + self.assertEqual(captured["payload"]["invocation"]["command"]["action"], "list") + self.assertEqual(captured["payload"]["invocation"]["sessionId"], "sid-1") + self.assertEqual(json.loads(captured["payload"]["invocation"]["argsJson"]), {"all": True}) + + def test_request_command_maps_request_exception(self): + def post_json_func(endpoint, payload, timeout_seconds): + raise TransportError("boom") + + def current_mode_name(): + return "editor" + + from csharpconsole_core.response_parser import parse_command_http_response + result = request_command(post_json_func, parse_command_http_response, current_mode_name, "session", "list") + self.assertFalse(result["ok"]) + self.assertEqual(result["type"], "system_error") + self.assertEqual(result["exitCode"], 3) + + +if __name__ == "__main__": + unittest.main() + + +class RuntimeStructureTests(unittest.TestCase): + def test_command_router_no_longer_uses_response_factory_from_result(self): + router_path = REPO_ROOT / "Runtime/Service/Commands/Routing/CommandRouter.cs" + router_source = router_path.read_text(encoding="utf-8") + self.assertNotIn("CommandResponseFactory.FromResult(", router_source) + + def test_command_router_no_longer_requires_context_only_signature(self): + router_path = REPO_ROOT / "Runtime/Service/Commands/Routing/CommandRouter.cs" + router_source = router_path.read_text(encoding="utf-8") + self.assertNotIn("CommandResponse Handler(CommandActionContext)", router_source) + + def test_runtime_command_flow_no_longer_contains_command_result_type(self): + command_result_path = REPO_ROOT / "Runtime/Service/Commands/Core/CommandResult.cs" + self.assertFalse(command_result_path.exists()) + + def test_runtime_command_flow_removes_command_action_context_type(self): + context_path = REPO_ROOT / "Runtime/Service/Commands/Core/CommandActionContext.cs" + self.assertFalse(context_path.exists()) + + def test_runtime_command_flow_adds_argument_binder(self): + binder_path = REPO_ROOT / "Runtime/Service/Commands/Core/CommandArgumentBinder.cs" + self.assertTrue(binder_path.exists()) + + def test_command_handlers_no_longer_use_context_parse_pattern(self): + handlers_dir = REPO_ROOT / "Runtime/Service/Commands/Handlers" + source = "\n".join( + path.read_text(encoding="utf-8") + for path in handlers_dir.glob("*CommandActions.cs") + ) + self.assertNotIn("TryParseArgs(", source) + self.assertNotIn("argsType: typeof(", source) + self.assertNotIn("CommandActionContext context", source) + + def test_runtime_command_flow_removes_command_args_parser(self): + parser_path = REPO_ROOT / "Runtime/Service/Commands/Core/CommandArgsParser.cs" + self.assertFalse(parser_path.exists()) diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_config_base.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_config_base.py new file mode 100644 index 0000000..04f45a7 --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_config_base.py @@ -0,0 +1,27 @@ +import argparse +import unittest + +import _bootstrap # noqa: F401 +from csharpconsole_core.config_base import SharedConfigState, add_common_connection_args, configure_shared_globals + + +class ConfigBaseTests(unittest.TestCase): + def test_add_common_connection_args_adds_shared_flags(self): + parser = argparse.ArgumentParser() + add_common_connection_args(parser, lambda p: p.add_argument('--extra', default='')) + args = parser.parse_args(['--ip', '127.0.0.1', '--port', '14500', '--extra', 'x']) + self.assertEqual(args.ip, '127.0.0.1') + self.assertEqual(args.port, 14500) + self.assertEqual(args.extra, 'x') + + def test_configure_shared_globals_runtime_sets_runtime_target(self): + state = SharedConfigState() + args = argparse.Namespace(ip='127.0.0.1', port=15500, editor=False, mode='runtime', runtime_dll_path='dlls', compile_ip='127.0.0.1', compile_port=14500) + configure_shared_globals(state, args) + self.assertTrue(state.runtime_mode) + self.assertEqual(state.runtime_ip, '127.0.0.1') + self.assertEqual(state.runtime_port, 15500) + + +if __name__ == '__main__': + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_models.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_models.py new file mode 100644 index 0000000..aee9935 --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_models.py @@ -0,0 +1,27 @@ +import unittest + +import _bootstrap # noqa: F401 +from csharpconsole_core.models import make_result, new_run_id + + +class ModelsTests(unittest.TestCase): + def test_make_result_populates_expected_fields(self): + result = make_result(True, "execute", "ok", 0, "done", "sid-1", "editor", run_id="run-1", duration_ms=12.7, data={"x": 1}) + self.assertEqual(result["ok"], True) + self.assertEqual(result["stage"], "execute") + self.assertEqual(result["type"], "ok") + self.assertEqual(result["exitCode"], 0) + self.assertEqual(result["summary"], "done") + self.assertEqual(result["sessionId"], "sid-1") + self.assertEqual(result["mode"], "editor") + self.assertEqual(result["runId"], "run-1") + self.assertEqual(result["durationMs"], 12) + self.assertEqual(result["data"], {"x": 1}) + + def test_new_run_id_has_timestamp_prefix(self): + run_id = new_run_id() + self.assertRegex(run_id, r"^\d{8}-\d{6}-[0-9a-f]{8}$") + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_output.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_output.py new file mode 100644 index 0000000..b8b9b9a --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_output.py @@ -0,0 +1,32 @@ +import io +import unittest +from contextlib import redirect_stdout + +import _bootstrap # noqa: F401 +from csharpconsole_core.output import emit_result, print_text_result, render_text_result + + +class OutputTests(unittest.TestCase): + def test_render_text_result_normalizes_escaped_whitespace(self): + rendered = render_text_result({"data": {"text": "a\\nb\\t1"}, "summary": ""}, lambda data: data.get("text", "")) + self.assertEqual(rendered, "a\nb\t1") + + def test_render_text_result_preserves_explicit_empty_text(self): + rendered = render_text_result({"data": {"text": ""}, "summary": "OK"}, lambda data: data["text"] if "text" in data else None) + self.assertEqual(rendered, "") + + def test_emit_result_outputs_json(self): + stream = io.StringIO() + with redirect_stdout(stream): + emit_result({"ok": True}, as_json=True, print_text=lambda _result: None) + self.assertIn('"ok": true', stream.getvalue().lower()) + + def test_print_text_result_uses_summary_on_error(self): + stream = io.StringIO() + with redirect_stdout(stream): + print_text_result({"ok": False, "summary": "boom", "data": {}}, lambda data: data.get("text", "")) + self.assertIn("boom", stream.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_response_parser.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_response_parser.py new file mode 100644 index 0000000..07fb3ec --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_response_parser.py @@ -0,0 +1,68 @@ +import json +import unittest + +import _bootstrap # noqa: F401 +from csharpconsole_core.response_parser import parse_command_http_response, parse_text_http_response + + +class ResponseParserTests(unittest.TestCase): + def test_parse_text_http_response_envelope_extracts_text(self): + raw = json.dumps({ + "ok": True, + "stage": "execute", + "type": "ok", + "summary": "done", + "sessionId": "sid-1", + "dataJson": json.dumps({"text": "hello"}), + }) + result = parse_text_http_response(raw, "execute", "sid-1", "editor", "run-1", 10) + self.assertTrue(result["ok"]) + self.assertEqual(result["data"]["text"], "hello") + + def test_parse_text_http_response_envelope_preserves_empty_text(self): + raw = json.dumps({ + "ok": True, + "stage": "execute", + "type": "ok", + "summary": "OK", + "sessionId": "sid-1", + "dataJson": json.dumps({"text": ""}), + }) + result = parse_text_http_response(raw, "execute", "sid-1", "editor", "run-1", 10) + self.assertTrue(result["ok"]) + self.assertIn("text", result["data"]) + self.assertEqual(result["data"]["text"], "") + + def test_parse_command_http_response_preserves_descriptor(self): + raw = json.dumps({ + "ok": False, + "stage": "command", + "type": "unsupported", + "summary": "Command cannot satisfy this request", + "sessionId": "sid-1", + "dataJson": json.dumps({ + "command": { + "commandNamespace": "project", + "action": "scene.open", + "summary": "Open a scene", + }, + "resultJson": {"reason": "missing-scene"}, + }), + }) + + result = parse_command_http_response(raw, "sid-1", "editor", "run-1", 10) + + self.assertFalse(result["ok"]) + self.assertEqual(result["type"], "unsupported") + self.assertEqual(result["data"]["command"]["summary"], "Open a scene") + self.assertEqual(result["data"]["resultJson"], {"reason": "missing-scene"}) + + def test_parse_text_http_response_legacy_forward_failure_is_runtime_error(self): + result = parse_text_http_response("Forward failed: timeout", "execute", "sid-1", "runtime", "run-1", 10) + self.assertFalse(result["ok"]) + self.assertEqual(result["type"], "runtime_error") + self.assertEqual(result["exitCode"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_runtime_artifacts_base.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_runtime_artifacts_base.py new file mode 100644 index 0000000..ede70cc --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_runtime_artifacts_base.py @@ -0,0 +1,29 @@ +import os +import tempfile +import unittest + +import _bootstrap # noqa: F401 +from csharpconsole_core.runtime_artifacts_base import prepare_runtime_artifacts, zip_directory + + +class RuntimeArtifactsBaseTests(unittest.TestCase): + def test_zip_directory_includes_extra_file_outside_root(self): + with tempfile.TemporaryDirectory() as root, tempfile.NamedTemporaryFile(delete=False) as extra: + try: + with open(os.path.join(root, 'a.txt'), 'w', encoding='utf-8') as f: + f.write('hello') + extra.write(b'defines') + extra.close() + data = zip_directory(root, extra.name, 'runtime-defines.txt') + self.assertTrue(len(data) > 0) + finally: + os.unlink(extra.name) + + def test_prepare_runtime_artifacts_editor_mode_short_circuits(self): + result = prepare_runtime_artifacts(False, '', '', 'runtime-defines.txt', None, None, None, 'runtimeDefinesPath') + self.assertTrue(result['ok']) + self.assertEqual(result['mode'], 'editor') + + +if __name__ == '__main__': + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_transport_http.py b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_transport_http.py new file mode 100644 index 0000000..cf83a3d --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_transport_http.py @@ -0,0 +1,73 @@ +import unittest +import urllib.error +from unittest.mock import patch + +import _bootstrap # noqa: F401 +from csharpconsole_core.transport_http import ( + TransportError, + post_binary, + post_json, + post_json_to_execute, +) + + +class _FakeResponse: + def __init__(self, body=b"ok", charset="utf-8"): + self._body = body + self._charset = charset + + class _Headers: + def __init__(self, charset): + self._charset = charset + + def get_content_charset(self): + return self._charset + + @property + def headers(self): + return self._Headers(self._charset) + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class TransportHttpTests(unittest.TestCase): + @patch("urllib.request.urlopen") + def test_post_json_returns_response_text(self, urlopen_mock): + urlopen_mock.return_value = _FakeResponse(b"ok") + result = post_json("http://127.0.0.1:14500/CSharpConsole", "health", {}, 2) + self.assertEqual(result, "ok") + urlopen_mock.assert_called_once() + + @patch("urllib.request.urlopen") + def test_post_json_to_execute_calls_execute_endpoint(self, urlopen_mock): + urlopen_mock.return_value = _FakeResponse(b"ok") + post_json_to_execute("http://127.0.0.1:14500/CSharpConsole", {"x": 1}, 2) + request = urlopen_mock.call_args[0][0] + self.assertTrue(request.full_url.endswith("/execute")) + self.assertEqual(request.get_method(), "POST") + + @patch("urllib.request.urlopen") + def test_post_binary_uses_octet_stream(self, urlopen_mock): + urlopen_mock.return_value = _FakeResponse(b"ok") + post_binary("http://x", b"data", 2) + request = urlopen_mock.call_args[0][0] + # urllib capitalizes header keys: "Content-Type" -> "Content-type". + self.assertEqual(request.headers.get("Content-type"), "application/octet-stream") + self.assertEqual(request.data, b"data") + + @patch("urllib.request.urlopen") + def test_transport_error_on_connection_failure(self, urlopen_mock): + urlopen_mock.side_effect = urllib.error.URLError("connection refused") + with self.assertRaises(TransportError): + post_json("http://127.0.0.1:14500/CSharpConsole", "health", {}, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_direct_launch.py b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_direct_launch.py new file mode 100644 index 0000000..805b788 --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_direct_launch.py @@ -0,0 +1,309 @@ +import os +import sys +import tempfile +import types +import unittest +from unittest import mock + +SCRIPT_ROOT = os.path.dirname(os.path.abspath(__file__)) +CONSOLE_CLIENT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_ROOT)) +SITE_PACKAGES_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "site-packages") +_ADDED_SITE_PACKAGES_PATH = False + +if CONSOLE_CLIENT_ROOT not in sys.path: + sys.path.insert(0, CONSOLE_CLIENT_ROOT) + +if SITE_PACKAGES_PATH not in sys.path: + sys.path.insert(0, SITE_PACKAGES_PATH) + _ADDED_SITE_PACKAGES_PATH = True + +_original_bootstrap_module = sys.modules.get("csharp_bootstrap") +_original_repl_core_module = sys.modules.get("csharp_repl_core") +sys.modules["csharp_bootstrap"] = types.SimpleNamespace(bootstrap_repl_dependencies=lambda: None, ensure_supported_python=lambda: None) +sys.modules["csharp_repl_core"] = types.SimpleNamespace(run_repl=lambda _args: None) + +CORE_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "csharpconsole_core") +_ADDED_CORE_PATH = False +if CORE_PATH not in sys.path: + sys.path.insert(0, CORE_PATH) + _ADDED_CORE_PATH = True + +try: + from repl import config, direct_launch + import csharp_repl as repl +finally: + if _original_bootstrap_module is not None: + sys.modules["csharp_bootstrap"] = _original_bootstrap_module + else: + del sys.modules["csharp_bootstrap"] + + if _original_repl_core_module is not None: + sys.modules["csharp_repl_core"] = _original_repl_core_module + else: + del sys.modules["csharp_repl_core"] + + if _ADDED_CORE_PATH: + sys.path.remove(CORE_PATH) + + if _ADDED_SITE_PACKAGES_PATH: + sys.path.remove(SITE_PACKAGES_PATH) + + +class ReplDirectLaunchHelpersTests(unittest.TestCase): + def test_extract_project_path_from_command_line_parses_quoted_project_path(self): + command_line = ( + '"C:/Program Files/Unity/Editor/Unity.exe" ' + '-batchmode ' + '-projectPath "E:/Unity Projects/PackagesDemo" ' + '-logFile -' + ) + + project_path = direct_launch.extract_project_path_from_command_line(command_line) + + self.assertEqual(project_path, "E:/Unity Projects/PackagesDemo") + + def test_extract_project_path_from_command_line_parses_unquoted_project_path(self): + command_line = ( + '"C:/Program Files/Unity/Editor/Unity.exe" ' + '-batchmode ' + '-projectPath E:/UnityProjects/PackagesDemo ' + '-logFile -' + ) + + project_path = direct_launch.extract_project_path_from_command_line(command_line) + + self.assertEqual(project_path, "E:/UnityProjects/PackagesDemo") + + def test_extract_project_path_from_command_line_parses_lowercase_projectpath(self): + command_line = ( + '"C:/Program Files/Unity/Editor/Unity.exe" ' + '-batchmode ' + '-projectpath "E:/UnityProjects/LowerCaseFlag" ' + '-logFile -' + ) + + project_path = direct_launch.extract_project_path_from_command_line(command_line) + + self.assertEqual(project_path, "E:/UnityProjects/LowerCaseFlag") + + def test_parse_windows_unity_processes_json_parses_pid_start_and_command_line(self): + output = ( + '[{"ProcessId":4132,"CreationDate":"20260330091501.123456+480",' + '"CommandLine":"C:/Program Files/Unity/Editor/Unity.exe ' + '-projectpath E:/UnityProjects/PackagesDemo"}]' + ) + + processes = direct_launch.parse_windows_unity_processes_json(output) + + self.assertEqual(len(processes), 1) + self.assertEqual(processes[0]["pid"], 4132) + self.assertIsInstance(processes[0]["create_time"], float) + self.assertIn("-projectpath", processes[0]["command_line"]) + + def test_read_project_temp_state_returns_none_when_temp_csharpconsole_dir_missing(self): + with tempfile.TemporaryDirectory() as temp_project: + result = direct_launch.read_project_temp_state(temp_project) + + self.assertIsNone(result) + + def test_discover_direct_launch_candidates_uses_effective_port_from_refresh_state(self): + fake_process = { + "pid": 111, + "create_time": 1000, + "command_line": '"Unity.exe" -projectPath "E:/UnityProjects/Healthy"', + } + fake_state = {"effectivePort": 14523} + + with mock.patch.object(direct_launch, "list_unity_editor_processes", return_value=[fake_process]), \ + mock.patch.object(direct_launch, "read_project_temp_state", return_value=fake_state) as read_state, \ + mock.patch.object(direct_launch, "probe_editor_health", return_value={"ok": True}) as probe_health: + result = direct_launch.discover_direct_launch_candidates() + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["port"], 14523) + read_state.assert_called_once_with("E:/UnityProjects/Healthy") + probe_health.assert_called_once_with(direct_launch.DEFAULT_LOOPBACK_HOST, 14523) + + def test_discover_direct_launch_candidates_skips_instance_without_refresh_state_port(self): + fake_process = { + "pid": 111, + "create_time": 1000, + "command_line": '"Unity.exe" -projectPath "E:/UnityProjects/Healthy"', + } + + with mock.patch.object(direct_launch, "list_unity_editor_processes", return_value=[fake_process]), \ + mock.patch.object(direct_launch, "read_project_temp_state", return_value={}) as read_state, \ + mock.patch.object(direct_launch, "probe_editor_health") as probe_health: + result = direct_launch.discover_direct_launch_candidates() + + self.assertEqual(result, []) + read_state.assert_called_once_with("E:/UnityProjects/Healthy") + probe_health.assert_not_called() + + def test_list_unity_editor_processes_skips_batchmode_workers(self): + output = ( + '[{"ProcessId":4132,"CreationDate":"20260330091501.123456+480",' + '"CommandLine":"Unity.exe -projectPath E:/UnityProjects/PackagesDemo"},' + '{"ProcessId":4133,"CreationDate":"20260330091501.123456+480",' + '"CommandLine":"Unity.exe -batchMode -name AssetImportWorker0 -projectPath E:/UnityProjects/PackagesDemo"}]' + ) + + processes = direct_launch.parse_windows_unity_processes_json(output) + + self.assertEqual([item["pid"] for item in processes], [4132]) + + def test_discover_direct_launch_candidates_keeps_only_healthy_editors(self): + fake_processes = [ + { + "pid": 111, + "create_time": 1000, + "command_line": '"Unity.exe" -projectPath "E:/UnityProjects/Healthy"', + }, + { + "pid": 222, + "create_time": 2000, + "command_line": '"Unity.exe" -projectPath "E:/UnityProjects/Unhealthy"', + }, + ] + + with mock.patch.object(direct_launch, "list_unity_editor_processes", return_value=fake_processes), \ + mock.patch.object(direct_launch, "read_project_temp_state", side_effect=[{"effectivePort": 14500}, {"effectivePort": 14501}]), \ + mock.patch.object(direct_launch, "probe_editor_health", side_effect=[{"ok": True}, {"ok": False}]): + result = direct_launch.discover_direct_launch_candidates() + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["pid"], 111) + self.assertEqual(result[0]["port"], 14500) + self.assertEqual(result[0]["projectPath"], "E:/UnityProjects/Healthy") + + def test_probe_editor_health_uses_post_with_json_and_timeout(self): + response = mock.MagicMock() + response.__enter__.return_value = response + response.getcode.return_value = 200 + + with mock.patch("urllib.request.urlopen", return_value=response) as urlopen_mock: + result = direct_launch.probe_editor_health("127.0.0.1", 14500, timeout_seconds=1.25) + + self.assertEqual(result, {"ok": True}) + urlopen_mock.assert_called_once() + request_arg, kwargs = urlopen_mock.call_args + request = request_arg[0] + self.assertEqual(request.full_url, "http://127.0.0.1:14500/CSharpConsole/health") + self.assertEqual(request.data, b"{}") + self.assertEqual(request.get_method(), "POST") + self.assertEqual(request.headers.get("Content-type"), "application/json") + self.assertEqual(kwargs.get("timeout"), 1.25) + + def test_format_direct_launch_candidate_label_returns_expected_text(self): + candidate = { + "pid": 31415, + "start": "2026-03-30 09:15:00", + "projectPath": "E:/UnityProjects/PackagesDemo", + } + + label = direct_launch.format_direct_launch_candidate_label(candidate) + + self.assertEqual( + label, + "PID 31415 | E:/UnityProjects/PackagesDemo", + ) + + def test_format_direct_launch_candidate_label_uses_only_pid_and_project_path(self): + candidate = { + "pid": 31415, + "start": "2026-03-30 09:15:00", + "projectPath": "E:/UnityProjects/PackagesDemo", + } + + label = direct_launch.format_direct_launch_candidate_label(candidate) + + self.assertEqual(label, "PID 31415 | E:/UnityProjects/PackagesDemo") + self.assertNotIn("2026-03-30 09:15:00", label) + + +class ReplDirectLaunchEntryTests(unittest.TestCase): + def test_main_uses_direct_launch_when_no_args(self): + direct_args = object() + with mock.patch.object(repl, "resolve_direct_launch_args", return_value=direct_args) as resolve_args, \ + mock.patch.object(repl, "parse_repl_args") as parse_args, \ + mock.patch.object(repl, "run_repl") as run_repl: + repl.main([]) + + resolve_args.assert_called_once_with() + parse_args.assert_not_called() + run_repl.assert_called_once_with(direct_args) + + def test_resolve_direct_launch_args_prints_discovery_status_messages(self): + candidate = {"pid": 101, "port": 14500, "projectPath": "A"} + status_messages = [] + + with mock.patch.object(direct_launch, "discover_direct_launch_candidates", return_value=[candidate]), \ + mock.patch.object(repl, "select_direct_launch_candidate", return_value=candidate): + result = repl.resolve_direct_launch_args(status_writer=status_messages.append) + + self.assertEqual( + status_messages, + [ + "Discovering Unity Editor instances...", + "Discovered 1 Unity Editor instance(s).", + ], + ) + self.assertEqual(result.port, 14500) + + def test_main_uses_parse_repl_args_when_args_present(self): + parsed_args = object() + with mock.patch.object(repl, "parse_repl_args", return_value=parsed_args) as parse_args, \ + mock.patch.object(repl, "resolve_direct_launch_args") as resolve_args, \ + mock.patch.object(repl, "run_repl") as run_repl: + repl.main(["--ip", "127.0.0.1", "--port", "14500", "--mode", "editor"]) + + parse_args.assert_called_once_with(["--ip", "127.0.0.1", "--port", "14500", "--mode", "editor"]) + resolve_args.assert_not_called() + run_repl.assert_called_once_with(parsed_args) + + def test_select_direct_launch_candidate_uses_numbered_text_prompt(self): + candidate_a = {"pid": 101, "port": 14500, "projectPath": "A"} + candidate_b = {"pid": 202, "port": 14501, "projectPath": "B"} + + with mock.patch.object(direct_launch, "format_direct_launch_candidate_label", side_effect=["A", "B"]) as format_label, \ + mock.patch("builtins.print") as print_mock, \ + mock.patch("builtins.input", return_value="2") as input_mock: + result = repl.select_direct_launch_candidate([candidate_a, candidate_b]) + + self.assertIs(result, candidate_b) + self.assertEqual(format_label.call_count, 2) + print_mock.assert_any_call("Select Unity Editor instance:") + print_mock.assert_any_call("1. A") + print_mock.assert_any_call("2. B") + input_mock.assert_called_once() + self.assertFalse(hasattr(repl, "radiolist_dialog")) + + def test_resolve_direct_launch_args_exits_cleanly_when_picker_cancelled(self): + candidate = {"pid": 101, "port": 14500, "projectPath": "A"} + + with mock.patch.object(direct_launch, "discover_direct_launch_candidates", return_value=[candidate]), \ + mock.patch.object(repl, "select_direct_launch_candidate", return_value=None): + with self.assertRaises(SystemExit) as cm: + repl.resolve_direct_launch_args() + + self.assertEqual(cm.exception.code, 0) + + def test_resolve_direct_launch_args_prints_empty_discovery_status_when_no_candidate_found(self): + status_messages = [] + + with mock.patch.object(direct_launch, "discover_direct_launch_candidates", return_value=[]): + with self.assertRaises(SystemExit) as cm: + repl.resolve_direct_launch_args(status_writer=status_messages.append) + + self.assertEqual(cm.exception.code, 0) + self.assertEqual( + status_messages, + [ + "Discovering Unity Editor instances...", + "No available Unity Editor instances found.", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_session_ui.py b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_session_ui.py new file mode 100644 index 0000000..b505fb5 --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_session_ui.py @@ -0,0 +1,76 @@ +import os +import sys +import types +import unittest + +SCRIPT_ROOT = os.path.dirname(os.path.abspath(__file__)) +CONSOLE_CLIENT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_ROOT)) +SITE_PACKAGES_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "site-packages") + +if CONSOLE_CLIENT_ROOT not in sys.path: + sys.path.insert(0, CONSOLE_CLIENT_ROOT) +if SITE_PACKAGES_PATH not in sys.path: + sys.path.insert(0, SITE_PACKAGES_PATH) + +from repl import session_ui + + +def _make_config(runtime_mode=False, ip="127.0.0.1", port=14500, runtime_ip="127.0.0.1", runtime_port=15500): + return types.SimpleNamespace( + runtime_mode=runtime_mode, + ip=ip, + port=port, + runtime_ip=runtime_ip, + runtime_port=runtime_port, + ) + + +def _flatten_values(parts): + return [text for _style, text in parts] + + +class BuildStartupBannerTests(unittest.TestCase): + def test_executor_mode_omitted_when_empty(self): + parts = session_ui.build_startup_banner(_make_config(), "cmd-1", executor_mode="") + joined = "".join(_flatten_values(parts)) + self.assertNotIn("executor=", joined) + + def test_executor_mode_omitted_when_not_passed(self): + # backwards-compat: callers from before the executor_mode arg landed + parts = session_ui.build_startup_banner(_make_config(), "cmd-1") + joined = "".join(_flatten_values(parts)) + self.assertNotIn("executor=", joined) + + def test_executor_mode_appended_when_hybridclr(self): + parts = session_ui.build_startup_banner(_make_config(runtime_mode=True), "cmd-1", executor_mode="hybridCLR") + joined = "".join(_flatten_values(parts)) + self.assertIn("executor=", joined) + self.assertIn("hybridCLR", joined) + + def test_executor_mode_appended_when_lite(self): + parts = session_ui.build_startup_banner(_make_config(runtime_mode=True), "cmd-1", executor_mode="lite") + joined = "".join(_flatten_values(parts)) + self.assertIn("executor=", joined) + self.assertIn("lite", joined) + + +class BuildFooterSessionTextTests(unittest.TestCase): + def test_executor_mode_omitted_when_empty(self): + parts = session_ui.build_footer_session_text(_make_config(), "cmd-1", executor_mode="") + joined = "".join(_flatten_values(parts)) + self.assertNotIn("executor=", joined) + + def test_executor_mode_omitted_when_not_passed(self): + parts = session_ui.build_footer_session_text(_make_config(), "cmd-1") + joined = "".join(_flatten_values(parts)) + self.assertNotIn("executor=", joined) + + def test_executor_mode_appended_when_hybridclr(self): + parts = session_ui.build_footer_session_text(_make_config(runtime_mode=True), "cmd-1", executor_mode="hybridCLR") + joined = "".join(_flatten_values(parts)) + self.assertIn("executor=", joined) + self.assertIn("hybridCLR", joined) + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_tab_completion.py b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_tab_completion.py new file mode 100644 index 0000000..a9dae3a --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_tab_completion.py @@ -0,0 +1,2119 @@ +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +SCRIPT_ROOT = os.path.dirname(os.path.abspath(__file__)) +CONSOLE_CLIENT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_ROOT)) +SITE_PACKAGES_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "site-packages") +_ADDED_SITE_PACKAGES_PATH = False + +if CONSOLE_CLIENT_ROOT not in sys.path: + sys.path.insert(0, CONSOLE_CLIENT_ROOT) + +if SITE_PACKAGES_PATH not in sys.path: + sys.path.insert(0, SITE_PACKAGES_PATH) + _ADDED_SITE_PACKAGES_PATH = True + +CORE_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "csharpconsole_core") +_ADDED_CORE_PATH = False +if CORE_PATH not in sys.path: + sys.path.insert(0, CORE_PATH) + _ADDED_CORE_PATH = True + +try: + from prompt_toolkit.keys import Keys + from prompt_toolkit.clipboard import InMemoryClipboard + from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard + from prompt_toolkit.layout.containers import FloatContainer + from prompt_toolkit.layout.menus import CompletionsMenu + from prompt_toolkit.key_binding.bindings.mouse import load_mouse_bindings + from prompt_toolkit.search import SearchDirection + from repl import builtins as repl_builtins + from repl import client, config, scroll_router, session_ui, viewport_policy + from repl.command_expr import ( + looks_like_command_expression_prefix, + parse_command_expression, +) + from repl.completion import CommandExpressionCompleter, RoslynCompleter + from repl.transcript import TranscriptEntry + import csharp_repl_core as repl +finally: + if _ADDED_CORE_PATH: + sys.path.remove(CORE_PATH) + + if _ADDED_SITE_PACKAGES_PATH: + sys.path.remove(SITE_PACKAGES_PATH) + + +class FakeCompletion: + def __init__(self, text, start_position=0): + self.text = text + self.display = text + self.start_position = start_position + + +class FakeCompletionState: + def __init__(self, *completion_texts, selected_index=None): + self.completions = [FakeCompletion(text) for text in completion_texts] + self.complete_index = selected_index + + def go_to_index(self, index): + self.complete_index = index + + @property + def current_completion(self): + if self.complete_index is None: + return None + if 0 <= self.complete_index < len(self.completions): + return self.completions[self.complete_index] + return None + + +class FakeBuffer: + def __init__(self, semantic_texts=(), selected_index=None): + self.complete_state = ( + FakeCompletionState(*semantic_texts, selected_index=selected_index) + if semantic_texts + else None + ) + self.inserted = [] + self.applied_completions = [] + self.text = "" + + def insert_text(self, text): + self.inserted.append(text) + self.text += text + + def apply_completion(self, completion): + self.applied_completions.append(completion.text) + if completion.start_position < 0: + self.text = self.text[: completion.start_position] + self.text += completion.text + self.complete_state = None + + +class FakeEvent: + def __init__(self, buffer): + self.current_buffer = buffer + + +class FakeDocument: + def __init__(self, text_before_cursor): + self.text_before_cursor = text_before_cursor + self.text = text_before_cursor + + +class FakeToolbarBuffer: + def __init__(self, text="", semantic_texts=(), selected_index=None): + self.document = FakeDocument(text) + self.complete_state = ( + FakeCompletionState(*semantic_texts, selected_index=selected_index) + if semantic_texts + else None + ) + + +class FakeHistory: + def __init__(self): + self.entries = [] + + def append_string(self, text): + self.entries.append(text) + + +class ReplStateOverride: + def __init__(self, *, runtime_mode, enableml, enable_completion, ip, port, compile_ip, compile_port, runtime_ip, runtime_port): + self.runtime_mode = runtime_mode + self.enableml = enableml + self.enable_completion = enable_completion + self.ip = ip + self.port = port + self.compile_ip = compile_ip + self.compile_port = compile_port + self.runtime_ip = runtime_ip + self.runtime_port = runtime_port + + def __enter__(self): + self.previous = { + "runtime_mode": config.runtime_mode, + "enableml": repl.enableml, + "enable_completion": repl.enable_completion, + "ip": config.ip, + "port": config.port, + "compile_ip": config.compile_ip, + "compile_port": config.compile_port, + "runtime_ip": config.runtime_ip, + "runtime_port": config.runtime_port, + } + config.runtime_mode = self.runtime_mode + repl.enableml = self.enableml + repl.enable_completion = self.enable_completion + config.ip = self.ip + config.port = self.port + config.compile_ip = self.compile_ip + config.compile_port = self.compile_port + config.runtime_ip = self.runtime_ip + config.runtime_port = self.runtime_port + return self + + def __exit__(self, exc_type, exc, tb): + config.runtime_mode = self.previous["runtime_mode"] + repl.enableml = self.previous["enableml"] + repl.enable_completion = self.previous["enable_completion"] + config.ip = self.previous["ip"] + config.port = self.previous["port"] + config.compile_ip = self.previous["compile_ip"] + config.compile_port = self.previous["compile_port"] + config.runtime_ip = self.previous["runtime_ip"] + config.runtime_port = self.previous["runtime_port"] + + +class FakeEventHook: + def __init__(self): + self.handlers = [] + + def __iadd__(self, handler): + self.handlers.append(handler) + return self + + +class ApplicationSpy: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.layout = kwargs.get("layout") + self.style = kwargs.get("style") + self.current_buffer = None + self.run_calls = 0 + self.invalidate_calls = 0 + + def run(self): + self.run_calls += 1 + return None + + def invalidate(self): + self.invalidate_calls += 1 + + +class FakeCurrentBufferForSubmit: + def __init__(self, text=""): + self.document = FakeDocument(text) + self.insertions = [] + + def insert_text(self, value): + self.insertions.append(value) + self.document.text += value + self.document.text_before_cursor += value + + +class FakeLayout: + def __init__(self): + self.focused = None + + def focus(self, target): + self.focused = target + + +class FakeAppForSubmit: + def __init__(self, text=""): + self.current_buffer = FakeCurrentBufferForSubmit(text) + self.exit_result = None + self.layout = FakeLayout() + + def exit(self, result=None): + self.exit_result = result + + +class BuiltinCommandCompletionTests(unittest.TestCase): + def create_builtin_registry(self): + registry = repl_builtins.BuiltinRegistry() + repl_builtins.register_default_builtins( + registry, + { + "set_enable_completion": lambda _enabled: None, + "roslyn_invalidate": lambda: None, + "invalidate_command_catalog": lambda: None, + "execute_repl_snippet": lambda _message, reset=False: None, + }, + ) + return registry + + def test_builtin_commands_use_slash_prefix_and_expected_order(self): + registry = self.create_builtin_registry() + + self.assertEqual( + registry.order, + [ + "/help", + "/completion", + "/theme", + "/using", + "/define", + "/reload", + "/reset", + "/clear", + "/dofile", + ], + "Builtin command completion order should advertise slash-prefixed commands", + ) + + def test_builtin_commands_advertise_slash_parameter_formats(self): + registry = self.create_builtin_registry() + + self.assertEqual( + registry.commands["/completion"]["completion"], + "/completion <0|1>", + "Completion builtin should advertise slash-prefixed usage", + ) + self.assertEqual( + registry.commands["/dofile"]["completion"], + "/dofile ", + "Dofile builtin should advertise slash-prefixed usage", + ) + + def test_builtin_completer_matches_line_start_slash_prefix(self): + completions = list(repl.builtin_cmd_completer.get_completions(FakeDocument("/do"), None)) + + self.assertTrue( + any(completion.text == "/dofile " for completion in completions), + "Builtin completer should suggest slash-prefixed builtin commands from line-start '/' input", + ) + + def test_builtin_completer_does_not_match_mid_line_slash(self): + completions = list(repl.builtin_cmd_completer.get_completions(FakeDocument("Debug.Log(\"x\"); /do"), None)) + + self.assertEqual( + completions, + [], + "Builtin completer should not activate for slash text that is not at line start", + ) + + def test_builtin_completer_ignores_line_comment_prefix(self): + completions = list(repl.builtin_cmd_completer.get_completions(FakeDocument("// comment"), None)) + + self.assertEqual( + completions, + [], + "Builtin completer should not activate for C# line comments that begin with '//'", + ) + + def test_builtin_completer_ignores_block_comment_prefix(self): + completions = list(repl.builtin_cmd_completer.get_completions(FakeDocument("/* comment"), None)) + + self.assertEqual( + completions, + [], + "Builtin completer should not activate for C# block comments that begin with '/*'", + ) + + +class FakeChangeBuffer: + def __init__(self, text_before_cursor): + self.document = FakeDocument(text_before_cursor) + self.start_completion_calls = [] + + def start_completion(self, select_first=False): + self.start_completion_calls.append(select_first) + + +class BuiltinCommandFeedbackTests(unittest.TestCase): + def create_builtin_registry(self): + registry = repl_builtins.BuiltinRegistry() + repl_builtins.register_default_builtins( + registry, + { + "set_enable_completion": lambda _enabled: None, + "roslyn_invalidate": lambda: None, + "invalidate_command_catalog": lambda: None, + "execute_repl_snippet": lambda _message, reset=False: None, + "clear_transcript": lambda: False, + }, + ) + return registry + + def test_open_local_file_queues_external_open_until_after_render_when_repl_is_running(self): + previous_get_app = repl_builtins.get_app_or_none + previous_run_in_terminal = repl_builtins.run_in_terminal + previous_startfile = getattr(repl_builtins.os, "startfile", None) + + calls = [] + + class _AppStub: + _is_running = True + def __init__(self): + self._csharpconsole_queue_external_open = lambda opener: calls.append(("queue", opener)) + + def _run_in_terminal(func, render_cli_done=False, in_executor=False): + calls.append(("run_in_terminal", render_cli_done, in_executor)) + return None + + try: + repl_builtins.get_app_or_none = lambda: _AppStub() + repl_builtins.run_in_terminal = _run_in_terminal + repl_builtins.os.startfile = lambda path: calls.append(("startfile", path)) + + repl_builtins.open_local_file("Defines.txt") + finally: + repl_builtins.get_app_or_none = previous_get_app + repl_builtins.run_in_terminal = previous_run_in_terminal + if previous_startfile is not None: + repl_builtins.os.startfile = previous_startfile + + self.assertEqual(calls[0][0], "queue") + self.assertTrue(callable(calls[0][1])) + self.assertEqual(len(calls), 1) + + def test_using_builtin_prints_manual_edit_help_without_opening_file(self): + registry = self.create_builtin_registry() + previous_path = repl_builtins.config._default_using_path + previous_open_config_file = repl_builtins.open_config_file + open_calls = [] + + try: + with tempfile.TemporaryDirectory() as temp_dir: + target_path = os.path.join(temp_dir, "DefaultUsing.cs") + repl_builtins.config._default_using_path = target_path + repl_builtins.open_config_file = lambda *args, **kwargs: open_calls.append((args, kwargs)) + + payload = repl_builtins.process_builtin_cmd("/using", registry.commands) + self.assertTrue(os.path.isfile(target_path)) + with open(target_path, "r", encoding="utf-8") as created_file: + self.assertEqual( + created_file.read(), + "// One using per line, for example:\n// using System;\n// using UnityEngine;\n", + ) + finally: + repl_builtins.config._default_using_path = previous_path + repl_builtins.open_config_file = previous_open_config_file + + self.assertTrue(payload["handled"]) + self.assertTrue(payload["result"]["ok"]) + output_text = payload["result"]["data"]["text"] + self.assertEqual(open_calls, []) + self.assertIn("Open this file and edit it manually:", output_text) + self.assertIn(os.path.abspath(target_path), output_text) + self.assertIn("using System;", output_text) + self.assertIn("Only lines in the form 'using Namespace;' are loaded.", output_text) + self.assertIn("After saving, run /reload", output_text) + + def test_define_builtin_prints_manual_edit_help_without_opening_file(self): + registry = self.create_builtin_registry() + previous_path = repl_builtins.config._default_define_path + previous_open_config_file = repl_builtins.open_config_file + open_calls = [] + + try: + with tempfile.TemporaryDirectory() as temp_dir: + target_path = os.path.join(temp_dir, "Defines.txt") + repl_builtins.config._default_define_path = target_path + repl_builtins.open_config_file = lambda *args, **kwargs: open_calls.append((args, kwargs)) + + payload = repl_builtins.process_builtin_cmd("/define", registry.commands) + self.assertTrue(os.path.isfile(target_path)) + with open(target_path, "r", encoding="utf-8") as created_file: + self.assertEqual( + created_file.read(), + "// Format: SYM1;SYM2;... Clear the file to use editor defaults\n", + ) + finally: + repl_builtins.config._default_define_path = previous_path + repl_builtins.open_config_file = previous_open_config_file + + self.assertTrue(payload["handled"]) + self.assertTrue(payload["result"]["ok"]) + output_text = payload["result"]["data"]["text"] + self.assertEqual(open_calls, []) + self.assertIn("Open this file and edit it manually:", output_text) + self.assertIn(os.path.abspath(target_path), output_text) + self.assertIn("SYM1;SYM2;SYM3", output_text) + self.assertIn("Only the first non-empty line that does not start with // is used.", output_text) + self.assertIn("Clear the file to use editor default defines.", output_text) + self.assertIn("After saving, run /reload", output_text) + + def test_builtin_clear_command_uses_transcript_clear_when_available(self): + registry = repl_builtins.BuiltinRegistry() + clear_calls = [] + repl_builtins.register_default_builtins( + registry, + { + "set_enable_completion": lambda _enabled: None, + "roslyn_invalidate": lambda: None, + "invalidate_command_catalog": lambda: None, + "execute_repl_snippet": lambda _message, reset=False: None, + "clear_transcript": lambda: clear_calls.append("clear") or True, + }, + ) + + previous_os_name = repl.os.name + previous_os_system = repl.os.system + os_calls = [] + try: + repl.os.name = "nt" + repl.os.system = lambda command: os_calls.append(command) + + payload = repl_builtins.process_builtin_cmd("/clear", registry.commands) + finally: + repl.os.name = previous_os_name + repl.os.system = previous_os_system + + self.assertTrue(payload["handled"]) + self.assertTrue(payload["result"]["ok"]) + self.assertEqual(clear_calls, ["clear"]) + self.assertEqual(os_calls, []) + self.assertEqual(payload["result"]["data"]["text"], "") + self.assertTrue(payload["result"]["data"]["silent"]) + + def test_builtin_clear_command_clears_terminal_without_default_success_message(self): + previous_os_name = repl.os.name + previous_os_system = repl.os.system + clear_calls = [] + try: + repl.os.name = "nt" + repl.os.system = lambda command: clear_calls.append(command) + + stream = io.StringIO() + with redirect_stdout(stream): + handled = repl.process_builtin_cmd("/clear") + + self.assertTrue(handled) + self.assertEqual(clear_calls, ["cls"]) + self.assertEqual(stream.getvalue(), "") + finally: + repl.os.name = previous_os_name + repl.os.system = previous_os_system + + def test_builtin_command_with_existing_output_keeps_existing_message(self): + previous_enable_completion = repl.enable_completion + try: + stream = io.StringIO() + with redirect_stdout(stream): + handled = repl.process_builtin_cmd("/completion 9") + + self.assertTrue(handled) + self.assertEqual(stream.getvalue(), "Usage: /completion 0|1\n\n") + finally: + repl.enable_completion = previous_enable_completion + + def test_process_builtin_cmd_ignores_non_slash_prefixed_input(self): + registry = repl_builtins.BuiltinRegistry() + + result = repl_builtins.process_builtin_cmd("@clear", registry.commands) + + self.assertEqual( + result, + {"handled": False, "result": None}, + "Legacy at-prefixed commands should not be treated as builtins anymore", + ) + + def test_process_builtin_cmd_ignores_line_comment_prefix(self): + registry = repl_builtins.BuiltinRegistry() + + result = repl_builtins.process_builtin_cmd("// comment", registry.commands) + + self.assertEqual( + result, + {"handled": False, "result": None}, + "C# line comments should be treated as code input, not slash builtins", + ) + + def test_process_builtin_cmd_reports_unknown_slash_command_as_builtin_error(self): + registry = repl_builtins.BuiltinRegistry() + + result = repl_builtins.process_builtin_cmd("/notacommand", registry.commands) + + self.assertTrue(result["handled"]) + payload = result["result"] + self.assertEqual(payload["ok"], False) + self.assertEqual(payload["type"], "builtin_error") + self.assertEqual(payload["summary"], "Unknown command: /notacommand") + self.assertEqual(payload["data"]["text"], "") + self.assertEqual(payload["data"]["silent"], False) + + +class AcceptCompletionTests(unittest.TestCase): + def invoke(self, buffer): + repl.accept_completion(FakeEvent(buffer)) + + def test_accepts_selected_completion(self): + buffer = FakeBuffer( + semantic_texts=("WriteLine", "Write"), + selected_index=0, + ) + + self.invoke(buffer) + + self.assertEqual( + buffer.applied_completions, + ["WriteLine"], + "Tab should accept the selected completion item", + ) + self.assertEqual( + buffer.inserted, + [], + "Completion should be applied via apply_completion instead of insert_text", + ) + self.assertIsNone( + buffer.complete_state, + "Completion state should be cleared after accepting", + ) + + def test_selects_first_item_when_none_selected(self): + buffer = FakeBuffer( + semantic_texts=("WriteLine", "Write"), + selected_index=None, + ) + + self.invoke(buffer) + + self.assertEqual( + buffer.applied_completions, + ["WriteLine"], + "Tab should auto-select and accept the first completion when none is selected", + ) + self.assertIsNone( + buffer.complete_state, + "Completion state should be cleared after accepting", + ) + + def test_no_completion_menu_is_no_op(self): + buffer = FakeBuffer() + + self.invoke(buffer) + + self.assertEqual( + buffer.inserted, + [], + "Tab should do nothing when no completion menu is open", + ) + self.assertIsNone( + buffer.complete_state, + "Completion state should remain empty when there is nothing to accept", + ) + + +class PromptToolkitIntegrationTests(unittest.TestCase): + def test_theme_command_shows_syntax_highlighted_code_preview_panel(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + self.assertFalse(session.theme_preview_container.filter()) + + session.default_buffer.text = "/theme material" + + self.assertTrue(session.theme_preview_container.filter()) + label_text = "".join(text for _style, text in session.theme_preview_label.text()) + preview_fragments = session.theme_preview_code.text() + preview_text = "".join(text for _style, text, *_rest in preview_fragments) + self.assertIn("material", label_text) + self.assertIn("public sealed class ThemePreview : MonoBehaviour", preview_text) + self.assertIn("// Comments, types, numbers, strings, null and interpolation", preview_text) + self.assertIn("[System.Serializable]", preview_text) + self.assertIn("List", preview_text) + self.assertIn("= true", preview_text) + self.assertIn("(string)null", preview_text) + self.assertIn('string player = "Codex"', preview_text) + self.assertIn('Debug.Log($"Player:{player}', preview_text) + self.assertLessEqual(len(preview_text.splitlines()), 9) + self.assertTrue(any("pygments." in style for style, _text, *_rest in preview_fragments)) + + session.default_buffer.text = "Debug.Log(1);" + self.assertFalse(session.theme_preview_container.filter()) + finally: + repl.theme_manager.clear_preview() + repl.Application = previous_application + repl.session = previous_session + + def test_ensure_prompt_session_enables_csharp_highlighting(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + lexer = session.lexer + self.assertIsNotNone( + lexer, + "REPL shell should configure a lexer so typed C# code is syntax highlighted", + ) + + highlighted_line = lexer.lex_document(repl.Document("using System;"))(0) + self.assertTrue( + any(text == "using" and style for style, text in highlighted_line), + "The C# lexer should apply a visible style to C# keywords like 'using'", + ) + self.assertEqual( + lexer.__class__.__name__, + "PygmentsLexer", + "REPL should use prompt_toolkit's PygmentsLexer adapter for C# highlighting", + ) + self.assertIsNotNone( + session.style, + "REPL shell should provide a Pygments-based style so lexer tokens render with visible colors", + ) + + def test_ensure_prompt_session_layout_restores_completion_menu_float(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertIsInstance( + session.app.layout.container, + FloatContainer, + "Custom REPL layout should be wrapped in FloatContainer so completion overlays can be rendered", + ) + completion_floats = [flt for flt in session.app.layout.container.floats if isinstance(flt.content, CompletionsMenu)] + self.assertTrue( + completion_floats, + "Custom REPL layout should include prompt_toolkit CompletionsMenu float so completion menu is visible", + ) + + def test_ensure_prompt_session_uses_supported_clipboard_backend(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertIsInstance( + session.app.kwargs.get("clipboard"), + (PyperclipClipboard, InMemoryClipboard), + "REPL application should provide either a system clipboard backend or the documented in-memory fallback", + ) + + def test_ensure_prompt_session_input_control_has_search_buffer(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertIsNotNone( + session.input_control.search_buffer_control, + "Input buffer control should be wired to a search buffer so Ctrl+R reverse search works again", + ) + + def test_transcript_input_fragments_use_lexer_styles(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + entry = TranscriptEntry(entry_type="input", text="using System;", created_at="2026-04-05T00:00:00.000Z") + fragments = session.transcript_control._render_entry_fragments(entry) + + self.assertTrue( + any("pygments.keyword" in style for style, _text, *_rest in fragments), + "Transcript input should reuse the configured lexer styles so C# code in history is syntax highlighted", + ) + + +class ReverseSearchBindingTests(unittest.TestCase): + def test_ctrl_r_binding_is_present(self): + bound_sequences = [binding.keys for binding in repl.bindings.bindings] + self.assertNotIn( + ("c-r",), + bound_sequences, + "Base REPL bindings should not shadow prompt_toolkit's emacs reverse-search binding", + ) + + def test_application_shell_restores_search_toolbar_control(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertIsNotNone(session.search_buffer_control) + self.assertIs(session.search_buffer_control, session.search_toolbar.control) + self.assertIs(session.input_control.search_buffer_control, session.search_buffer_control) + + def test_application_shell_search_toolbar_prompt_shows_search_shortcuts(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + processor = session.search_buffer_control.input_processors[0] + search_state = type("SearchStateStub", (), {"direction": SearchDirection.BACKWARD})() + session.search_toolbar.control.searcher_search_state = search_state + + import prompt_toolkit.widgets.toolbars as toolbars_module + original_toolbars_get_app = toolbars_module.get_app + toolbars_module.get_app = lambda: type( + "AppStub", + (), + {"layout": type("LayoutStub", (), {"search_links": [session.search_toolbar.control]})()}, + )() + try: + prompt_text = processor.text() + finally: + toolbars_module.get_app = original_toolbars_get_app + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(prompt_text, "") + + def test_is_search_active_reads_layout_search_flag(self): + previous_get_app = repl.get_app + try: + repl.get_app = lambda: type( + "AppStub", + (), + {"layout": type("LayoutStub", (), {"is_searching": True})()}, + )() + self.assertTrue(repl._is_search_active()) + finally: + repl.get_app = previous_get_app + + +class ScrollRouterModuleTests(unittest.TestCase): + def test_scroll_router_prioritizes_completion_then_transcript(self): + self.assertEqual( + scroll_router.resolve_wheel_target(completion_open=True), + scroll_router.WHEEL_TARGET_COMPLETION, + ) + self.assertEqual( + scroll_router.resolve_wheel_target(completion_open=False), + scroll_router.WHEEL_TARGET_TRANSCRIPT, + ) + + +class ViewportPolicyModuleTests(unittest.TestCase): + def test_compute_input_visible_lines_caps_at_max(self): + self.assertEqual(viewport_policy.compute_input_visible_lines("one", max_visible_lines=8), 1) + self.assertEqual(viewport_policy.compute_input_visible_lines("a\nb\nc", max_visible_lines=8), 3) + self.assertEqual(viewport_policy.compute_input_visible_lines("\n".join(str(i) for i in range(20)), max_visible_lines=8), 8) + + def test_is_transcript_at_bottom_uses_render_info(self): + window = type("WindowStub", (), {"render_info": type("RenderInfo", (), {"vertical_scroll": 12, "content_height": 20, "window_height": 8})()})() + self.assertTrue(viewport_policy.is_transcript_at_bottom(window)) + + window = type("WindowStub", (), {"render_info": type("RenderInfo", (), {"vertical_scroll": 10, "content_height": 20, "window_height": 8})()})() + self.assertFalse(viewport_policy.is_transcript_at_bottom(window)) + + +class MouseSupportTests(unittest.TestCase): + def test_application_shell_enables_prompt_toolkit_mouse_support(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertTrue( + session.app.kwargs.get("mouse_support"), + "REPL application should enable prompt_toolkit mouse support so wheel behavior follows the active subwindow", + ) + + def test_base_bindings_override_scroll_wheel_keys_for_transcript_routing(self): + bound_sequences = [binding.keys for binding in repl.bindings.bindings] + self.assertIn((Keys.ScrollUp,), bound_sequences) + self.assertIn((Keys.ScrollDown,), bound_sequences) + self.assertIn((Keys.WindowsMouseEvent,), bound_sequences) + self.assertIn((Keys.Vt100MouseEvent,), bound_sequences) + + def test_application_merged_key_bindings_include_default_mouse_bindings(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + merged = session.app.kwargs.get("key_bindings") + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertIsNotNone(merged) + self.assertIsNotNone(load_mouse_bindings()) + + def test_scroll_up_binding_calls_transcript_window_scroll(self): + previous_session = repl.session + previous_search = repl.get_app + + class _WindowStub: + def __init__(self): + self.up_calls = 0 + self.render_info = object() + + def _scroll_up(self): + self.up_calls += 1 + + class _ShellStub: + def __init__(self): + self.transcript_window = _WindowStub() + self.default_buffer = type("BufferStub", (), {"complete_state": None})() + + def scroll_transcript_window_up(self): + self.transcript_window._scroll_up() + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": False})() + try: + scroll_up_binding = next(binding for binding in repl.bindings.bindings if binding.keys == (Keys.ScrollUp,)) + scroll_up_binding.handler(type("FakeScrollEvent", (), {})()) + finally: + repl.get_app = previous_search + repl.session = previous_session + + self.assertEqual(shell.transcript_window.up_calls, 1) + + def test_scroll_down_binding_calls_transcript_window_scroll(self): + previous_session = repl.session + previous_search = repl.get_app + + class _WindowStub: + def __init__(self): + self.down_calls = 0 + self.render_info = object() + + def _scroll_down(self): + self.down_calls += 1 + + class _ShellStub: + def __init__(self): + self.transcript_window = _WindowStub() + self.default_buffer = type("BufferStub", (), {"complete_state": None})() + + def scroll_transcript_window_down(self): + self.transcript_window._scroll_down() + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": False})() + try: + scroll_down_binding = next(binding for binding in repl.bindings.bindings if binding.keys == (Keys.ScrollDown,)) + scroll_down_binding.handler(type("FakeScrollEvent", (), {})()) + finally: + repl.get_app = previous_search + repl.session = previous_session + + self.assertEqual(shell.transcript_window.down_calls, 1) + + def test_scroll_up_binding_routes_to_transcript_during_search(self): + previous_session = repl.session + previous_search = repl.get_app + + class _WindowStub: + def __init__(self): + self.up_calls = 0 + + def _scroll_up(self): + self.up_calls += 1 + + class _ShellStub: + def __init__(self): + self.transcript_window = _WindowStub() + self.default_buffer = type("BufferStub", (), {"complete_state": None})() + + def scroll_transcript_window_up(self): + self.transcript_window._scroll_up() + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": True})() + try: + scroll_up_binding = next(binding for binding in repl.bindings.bindings if binding.keys == (Keys.ScrollUp,)) + scroll_up_binding.handler(type("FakeScrollEvent", (), {})()) + finally: + repl.get_app = previous_search + repl.session = previous_session + + self.assertEqual(shell.transcript_window.up_calls, 1) + + def test_scroll_up_binding_does_not_route_to_transcript_when_completion_menu_open(self): + previous_session = repl.session + previous_search = repl.get_app + + class _WindowStub: + def __init__(self): + self.up_calls = 0 + + def _scroll_up(self): + self.up_calls += 1 + + class _ShellStub: + def __init__(self): + self.transcript_window = _WindowStub() + self.default_buffer = type("BufferStub", (), {"complete_state": object()})() + + def scroll_transcript_window_up(self): + self.transcript_window._scroll_up() + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": False})() + try: + scroll_up_binding = next(binding for binding in repl.bindings.bindings if binding.keys == (Keys.ScrollUp,)) + scroll_up_binding.handler(type("FakeScrollEvent", (), {})()) + finally: + repl.get_app = previous_search + repl.session = previous_session + + self.assertEqual(shell.transcript_window.up_calls, 0) + + +class CompletionTriggerTests(unittest.TestCase): + def test_dot_still_triggers_semantic_completion(self): + buffer = FakeChangeBuffer("Debug.") + + repl._trigger_completion_on_change(buffer) + + self.assertEqual( + buffer.start_completion_calls, + [False], + "Typing '.' should trigger semantic completion without auto-selecting the first item", + ) + + def test_line_start_slash_triggers_builtin_completion(self): + buffer = FakeChangeBuffer("/") + + repl._trigger_completion_on_change(buffer) + + self.assertEqual( + buffer.start_completion_calls, + [False], + "Typing '/' at line start should trigger builtin command completion", + ) + + def test_line_start_slash_still_triggers_builtin_completion_when_semantic_completion_is_disabled(self): + previous = repl.enable_completion + repl.enable_completion = False + try: + buffer = FakeChangeBuffer("/") + + repl._trigger_completion_on_change(buffer) + + self.assertEqual( + buffer.start_completion_calls, + [False], + "Builtin command completion should remain available at line-start '/' even when semantic completion is disabled", + ) + finally: + repl.enable_completion = previous + + def test_builtin_completion_stops_after_command_arguments_begin(self): + buffer = FakeChangeBuffer("/dofile test.cs") + + repl._trigger_completion_on_change(buffer) + + self.assertEqual( + buffer.start_completion_calls, + [], + "Builtin command completion should stop once the user starts typing command arguments", + ) + + def test_mid_line_slash_does_not_trigger_builtin_completion(self): + buffer = FakeChangeBuffer("foo /") + + repl._trigger_completion_on_change(buffer) + + self.assertEqual( + buffer.start_completion_calls, + [], + "Typing '/' away from line start should not hijack normal code entry with builtin command completion", + ) + + def test_line_comment_prefix_does_not_trigger_builtin_completion(self): + buffer = FakeChangeBuffer("//") + + repl._trigger_completion_on_change(buffer) + + self.assertEqual( + buffer.start_completion_calls, + [], + "Typing '//' at line start should be treated as normal C# comment input, not builtin completion", + ) + + def test_block_comment_prefix_does_not_trigger_builtin_completion(self): + buffer = FakeChangeBuffer("/*") + + repl._trigger_completion_on_change(buffer) + + self.assertEqual( + buffer.start_completion_calls, + [], + "Typing '/*' at line start should be treated as normal C# comment input, not builtin completion", + ) + + +class CommandExpressionPrefixAndParsingTests(unittest.TestCase): + def test_command_expression_prefix_requires_action_call_shape(self): + self.assertFalse(looks_like_command_expression_prefix("@")) + self.assertFalse(looks_like_command_expression_prefix("@game")) + self.assertTrue(looks_like_command_expression_prefix(" @game.pause")) + self.assertTrue(looks_like_command_expression_prefix("@game.pause()")) + self.assertTrue(looks_like_command_expression_prefix("@game.scene.pause()")) + self.assertTrue(looks_like_command_expression_prefix("@editor.status")) + + def test_command_expression_prefix_treats_escaped_identifiers_as_csharp_code(self): + self.assertFalse(looks_like_command_expression_prefix("@class")) + self.assertFalse(looks_like_command_expression_prefix(" @namespace")) + + def test_command_expression_prefix_rejects_legacy_dollar_symbol(self): + self.assertFalse(looks_like_command_expression_prefix("$")) + self.assertFalse(looks_like_command_expression_prefix("$game.pause")) + + def test_parse_command_expression_accepts_at_prefixed_action(self): + parsed = parse_command_expression("@game.pause()") + + self.assertEqual(parsed, ("game", "pause", {})) + + def test_parse_command_expression_accepts_dotted_action(self): + parsed = parse_command_expression("@game.scene.pause(target: \"Player\")") + + self.assertEqual(parsed, ("game", "scene.pause", {"target": "Player"})) + + def test_parse_command_expression_rejects_legacy_dollar_prefixed_action(self): + parsed = parse_command_expression("$game.pause()") + + self.assertIsNone(parsed) + + +class CommandActionCompletionRoutingTests(unittest.TestCase): + def test_action_completion_is_driven_by_at_prefix(self): + completer = CommandExpressionCompleter( + lambda: [ + { + "commandNamespace": "game", + "action": "pause", + "arguments": [], + } + ] + ) + + completions = list(completer.get_completions(FakeDocument("@game.p"), None)) + + self.assertTrue( + any(completion.text == "pause" for completion in completions), + "@-prefixed action text should use command action completion", + ) + + def test_action_completion_uses_catalog_summary_and_signature(self): + completer = CommandExpressionCompleter( + lambda: [ + { + "commandNamespace": "project", + "action": "scene.open", + "summary": "Open a Unity scene", + "arguments": [{"name": "scenePath", "typeName": "System.String"}], + } + ] + ) + + completions = list(completer.get_completions(FakeDocument("@project.sc"), None)) + completion_by_text = {completion.text: completion for completion in completions} + + self.assertIn("scene.open", completion_by_text) + self.assertEqual(completion_by_text["scene.open"].display_meta_text, "(scenePath: String) Open a Unity scene") + + def test_multi_segment_action_prefix_continues_to_complete_after_second_dot(self): + completer = CommandExpressionCompleter( + lambda: [ + { + "commandNamespace": "project", + "action": "scene.open", + "summary": "Open a Unity scene", + "arguments": [], + } + ] + ) + + completions = list(completer.get_completions(FakeDocument("@project.scene.o"), None)) + + self.assertTrue( + any(completion.text == "open" for completion in completions), + "Dotted action prefixes should keep matching so completions continue after the first dot", + ) + + def test_roslyn_completer_does_not_participate_for_slash_prefixed_input(self): + previous_request_completion = client.request_completion + requests = [] + + def _request_completion_stub(*args, **kwargs): + requests.append((args, kwargs)) + return {"ok": True, "data": {"items": []}, "summary": ""} + + client.request_completion = _request_completion_stub + try: + completer = RoslynCompleter(lambda: True, lambda: 1) + completions = list(completer.get_completions(FakeDocument("/game.pause"), None)) + finally: + client.request_completion = previous_request_completion + + self.assertEqual(completions, []) + self.assertEqual( + requests, + [], + "Roslyn completion should not run for slash-prefixed command input", + ) + + def test_roslyn_completer_does_not_participate_for_at_prefixed_input(self): + previous_request_completion = client.request_completion + requests = [] + + def _request_completion_stub(*args, **kwargs): + requests.append((args, kwargs)) + return {"ok": True, "data": {"items": []}, "summary": ""} + + client.request_completion = _request_completion_stub + try: + completer = RoslynCompleter(lambda: True, lambda: 1) + completions = list(completer.get_completions(FakeDocument("@game.pause"), None)) + finally: + client.request_completion = previous_request_completion + + self.assertEqual(completions, []) + self.assertEqual( + requests, + [], + "Roslyn completion should not run for @-prefixed action input", + ) + + def test_roslyn_completer_participates_for_escaped_identifier_input(self): + previous_request_completion = client.request_completion + requests = [] + + def _request_completion_stub(*args, **kwargs): + requests.append((args, kwargs)) + return {"ok": True, "data": {"items": []}, "summary": ""} + + client.request_completion = _request_completion_stub + try: + completer = RoslynCompleter(lambda: True, lambda: 1) + completions = list(completer.get_completions(FakeDocument("@class."), None)) + finally: + client.request_completion = previous_request_completion + + self.assertEqual(completions, []) + self.assertEqual( + len(requests), + 1, + "Roslyn completion should run for escaped C# identifiers like '@class.'", + ) + + +class InputSizingTests(unittest.TestCase): + def test_input_height_stays_one_line_for_single_line_text(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + session.default_buffer.text = "Debug.Log(1);" + height = session._get_input_height() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(height.preferred, 1) + self.assertEqual(height.min, height.preferred) + + def test_input_height_grows_for_multiline_text(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + session.default_buffer.text = "line1\nline2\nline3" + height = session._get_input_height() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(height.preferred, 3) + self.assertEqual(height.min, height.preferred) + + def test_input_height_caps_at_max_visible_lines_for_long_multiline_text(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + session.default_buffer.text = "\n".join(f"line{i}" for i in range(12)) + height = session._get_input_height() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(height.min, repl.MAX_INPUT_VISIBLE_LINES) + self.assertEqual(height.max, repl.MAX_INPUT_VISIBLE_LINES) + self.assertEqual(height.preferred, repl.MAX_INPUT_VISIBLE_LINES) + + +class TranscriptAutoScrollTests(unittest.TestCase): + def test_append_input_transcript_scrolls_to_bottom(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + session.transcript_window.vertical_scroll = 0 + session.append_input_transcript("Debug.Log(1);") + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(len(session.transcript_state.entries), 1) + self.assertGreaterEqual(session.transcript_window.vertical_scroll, 0) + self.assertGreater(session.app.invalidate_calls, 0) + + def test_scroll_transcript_to_bottom_uses_render_info_when_available(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + session.transcript_window.render_info = type("RenderInfo", (), {"content_height": 30, "window_height": 8})() + session.scroll_transcript_to_bottom() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(session.transcript_window.vertical_scroll, 22) + + def test_input_height_change_preserves_transcript_tail_when_at_bottom(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + session.transcript_window.render_info = type("RenderInfo", (), {"vertical_scroll": 12, "content_height": 20, "window_height": 8})() + session.default_buffer.text = "line1\nline2" + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(session.transcript_window.vertical_scroll, 12) + self.assertGreater(session.app.invalidate_calls, 0) + + +class InteractiveRunModelTests(unittest.TestCase): + def test_handle_submitted_message_processes_input_without_exiting_application(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + processed = [] + try: + session = repl.ensure_prompt_session() + app = FakeAppForSubmit("Debug.Log(1);") + event = type("FakeSubmitEvent", (), {"app": app})() + session.run_interactive(lambda message: processed.append(message)) + session._on_submit = lambda message: processed.append(message) + session.handle_submitted_message("Debug.Log(1);", event) + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertEqual(processed, ["Debug.Log(1);"]) + self.assertEqual(app.exit_result, None) + self.assertEqual(app.layout.focused, session.input_control) + self.assertEqual(session.app.run_calls, 1) + + +class SubmitBindingTests(unittest.TestCase): + def test_enter_submits_by_default(self): + previous_session = repl.session + previous_pending = repl._pending_quit_confirmation + try: + repl._pending_quit_confirmation = False + repl.session = type("FakeSession", (), {"history": FakeHistory()})() + app = FakeAppForSubmit("var a = 1;") + event = type("FakeEnterEvent", (), {"app": app, "current_buffer": app.current_buffer})() + + enter_binding = next(binding for binding in repl.bindings.bindings if binding.keys == ("c-m",)) + enter_binding.handler(event) + + self.assertEqual(app.exit_result, "var a = 1;") + self.assertEqual(repl.session.history.entries, ["var a = 1;"]) + finally: + repl.session = previous_session + repl._pending_quit_confirmation = previous_pending + + def test_ctrl_j_inserts_newline_without_submitting(self): + previous_session = repl.session + previous_pending = repl._pending_quit_confirmation + try: + repl._pending_quit_confirmation = False + repl.session = type("FakeSession", (), {"history": FakeHistory()})() + app = FakeAppForSubmit("var a = 1;") + event = type("FakeCtrlJEvent", (), {"app": app, "current_buffer": app.current_buffer})() + + newline_binding = next(binding for binding in repl.bindings.bindings if binding.keys == ("c-j",)) + newline_binding.handler(event) + + self.assertEqual(app.current_buffer.insertions, ["\n"]) + self.assertEqual(app.current_buffer.document.text, "var a = 1;\n") + self.assertIsNone(app.exit_result) + self.assertEqual(repl.session.history.entries, []) + finally: + repl.session = previous_session + repl._pending_quit_confirmation = previous_pending + + +class MultilineSubmitBindingTests(unittest.TestCase): + def test_escape_enter_submit_sequence_is_not_bound(self): + bound_sequences = [binding.keys for binding in repl.bindings.bindings] + + self.assertNotIn( + ("escape", "c-m"), + bound_sequences, + "Esc+Enter submit should remain unbound", + ) + + def test_escape_is_not_bound_as_standalone_clear_key(self): + bound_sequences = [binding.keys for binding in repl.bindings.bindings] + + self.assertNotIn( + ("escape",), + bound_sequences, + "Standalone Esc binding should be removed so Esc no longer enters prompt_toolkit's odd prefix/repeat state during normal editing", + ) + + +class EscapeAndCtrlCTests(unittest.TestCase): + def test_pending_quit_confirmation_filter_tracks_state(self): + previous = repl._pending_quit_confirmation + try: + repl._pending_quit_confirmation = False + self.assertFalse( + repl._pending_quit_confirmation_filter(), + "Pending-quit filter must stay inactive during normal typing so prompt_toolkit self-insert keeps working", + ) + + repl._pending_quit_confirmation = True + self.assertTrue( + repl._pending_quit_confirmation_filter(), + "Pending-quit filter should activate only while quit confirmation is pending", + ) + finally: + repl._pending_quit_confirmation = previous + + def test_ctrl_c_copies_selection_instead_of_clearing_input(self): + previous = repl._pending_quit_confirmation + repl._pending_quit_confirmation = False + try: + class FakeClipboard: + def __init__(self): + self.data = None + + def set_data(self, data): + self.data = data + + class FakeBufferForCopy: + def __init__(self): + self.text = "Debug.Log(1);" + self.document = FakeDocument(self.text) + self.selection_state = object() + self.reset_called = False + self.copy_calls = 0 + + def copy_selection(self): + self.copy_calls += 1 + self.selection_state = None + return "copied-text" + + def reset(self): + self.reset_called = True + + class FakeApp: + def __init__(self): + self.exited = False + self.clipboard = FakeClipboard() + + def exit(self, result=None): + self.exited = True + + buffer = FakeBufferForCopy() + event = type("FakeCtrlCEvent", (), {"app": FakeApp(), "current_buffer": buffer})() + + repl.handle_ctrl_c(event) + + self.assertEqual(buffer.copy_calls, 1) + self.assertFalse(buffer.reset_called) + self.assertEqual(event.app.clipboard.data, "copied-text") + self.assertFalse(repl._pending_quit_confirmation) + self.assertFalse(event.app.exited) + finally: + repl._pending_quit_confirmation = previous + + def test_ctrl_c_copies_transcript_selection_while_input_keeps_focus(self): + previous = repl._pending_quit_confirmation + previous_session = repl.session + repl._pending_quit_confirmation = False + try: + class FakeClipboard: + def __init__(self): + self.data = None + + def set_data(self, data): + self.data = data + + class FakeTranscriptControl: + def __init__(self): + self.selection_state = object() + self.copy_calls = 0 + + def copy_selection(self): + self.copy_calls += 1 + self.selection_state = None + return "transcript-copied" + + class FakeApp: + def __init__(self): + self.exited = False + self.clipboard = FakeClipboard() + self.invalidate_calls = 0 + + def exit(self, result=None): + self.exited = True + + def invalidate(self): + self.invalidate_calls += 1 + + transcript_control = FakeTranscriptControl() + repl.session = type("SessionStub", (), {"transcript_control": transcript_control, "app": FakeApp()})() + event = type("FakeCtrlCEvent", (), {"app": repl.session.app, "current_buffer": FakeToolbarBuffer("Debug.Log(1);")})() + + repl.handle_ctrl_c(event) + + self.assertEqual(transcript_control.copy_calls, 1) + self.assertEqual(event.app.clipboard.data, "transcript-copied") + self.assertFalse(repl._pending_quit_confirmation) + self.assertFalse(event.app.exited) + self.assertEqual(event.app.invalidate_calls, 1) + finally: + repl.session = previous_session + repl._pending_quit_confirmation = previous + + def test_ctrl_c_clears_current_input_text_when_buffer_has_text(self): + previous = repl._pending_quit_confirmation + repl._pending_quit_confirmation = False + try: + class FakeBufferForReset: + def __init__(self): + self.text = "Debug.Log(1);" + self.document = FakeDocument(self.text) + self.selection_state = None + self.reset_called = False + + def reset(self): + self.text = "" + self.document = FakeDocument("") + self.reset_called = True + + class FakeApp: + def __init__(self): + self.exited = False + + def exit(self, result=None): + self.exited = True + + buffer = FakeBufferForReset() + event = type("FakeCtrlCEvent", (), {"app": FakeApp(), "current_buffer": buffer})() + + repl.handle_ctrl_c(event) + + self.assertTrue(buffer.reset_called) + self.assertEqual(buffer.text, "") + self.assertFalse(repl._pending_quit_confirmation) + self.assertFalse(event.app.exited) + finally: + repl._pending_quit_confirmation = previous + + def test_first_ctrl_c_sets_pending_quit_confirmation(self): + previous = repl._pending_quit_confirmation + repl._pending_quit_confirmation = False + try: + class FakeApp: + def __init__(self): + self.exited = False + + def exit(self, result=None): + self.exited = True + + event = type( + "FakeCtrlCEvent", + (), + {"app": FakeApp(), "current_buffer": FakeToolbarBuffer()}, + )() + + repl.handle_ctrl_c(event) + + self.assertTrue(repl._pending_quit_confirmation) + self.assertFalse(event.app.exited) + finally: + repl._pending_quit_confirmation = previous + + def test_second_ctrl_c_exits_when_confirmation_is_pending(self): + previous = repl._pending_quit_confirmation + repl._pending_quit_confirmation = True + try: + class FakeApp: + def __init__(self): + self.exited = False + + def exit(self, result=None): + self.exited = True + + event = type( + "FakeCtrlCEvent", + (), + {"app": FakeApp(), "current_buffer": FakeToolbarBuffer()}, + )() + + repl.handle_ctrl_c(event) + + self.assertFalse(repl._pending_quit_confirmation) + self.assertTrue(event.app.exited) + finally: + repl._pending_quit_confirmation = previous + + +class FooterSessionTextTests(unittest.TestCase): + def test_footer_session_text_shows_mode_target_and_command_id(self): + with ReplStateOverride( + runtime_mode=False, + enableml=1, + enable_completion=True, + ip="127.0.0.1", + port=14500, + compile_ip="127.0.0.1", + compile_port=14500, + runtime_ip="127.0.0.1", + runtime_port=15500, + ): + self.assertEqual( + session_ui.build_footer_session_text(config, "cmd-123"), + [ + ("class:footer.session.label", "[session] "), + ("class:footer.session.key", "workMode="), + ("class:footer.session.value", "editor"), + ("", " "), + ("class:footer.session.key", "target="), + ("class:footer.session.value", "127.0.0.1:14500"), + ("", " "), + ("class:footer.session.key", "cmdId="), + ("class:footer.session.value", "cmd-123"), + ], + ) + + +class CommonTextResultTests(unittest.TestCase): + def test_text_result_leaves_blank_line_between_outputs(self): + import io + from contextlib import redirect_stdout + + stream = io.StringIO() + with redirect_stdout(stream): + client.print_text_result({"ok": True, "data": {"text": "alpha"}}) + client.print_text_result({"ok": True, "data": {"text": "beta"}}) + + self.assertEqual(stream.getvalue(), "alpha\nbeta\n") + + +class PromptStyleTests(unittest.TestCase): + def test_session_ui_style_rules_include_footer_and_transcript_tokens(self): + style_rules = dict(session_ui.build_session_style_rules()) + + self.assertIn("footer.session.label", style_rules) + self.assertIn("footer.session.key", style_rules) + self.assertIn("footer.session.value", style_rules) + self.assertIn("footer.status.left", style_rules) + self.assertIn("footer.status.right", style_rules) + self.assertIn("transcript.timestamp", style_rules) + self.assertIn("transcript.separator", style_rules) + self.assertIn("input.divider", style_rules) + self.assertIn("transcript.info.prefix", style_rules) + self.assertIn("transcript.input.prefix", style_rules) + self.assertIn("transcript.input.text", style_rules) + self.assertIn("transcript.result.prefix", style_rules) + self.assertIn("transcript.result.text", style_rules) + self.assertIn("transcript.notice.accessibility.text", style_rules) + self.assertIn("transcript.error.compile_error.prefix", style_rules) + self.assertIn("transcript.error.action_required.text", style_rules) + self.assertIn("transcript.error.timeout_error.prefix", style_rules) + self.assertIn("transcript.error.connection_error.prefix", style_rules) + self.assertIn("transcript.error.transport_error.prefix", style_rules) + self.assertIn("transcript.error.command_error.prefix", style_rules) + + def _bg(style): + for token in style.split(): + if token.startswith("bg:"): + return token + return None + + compile_bg = _bg(style_rules["transcript.error.compile_error.prefix"]) + action_bg = _bg(style_rules["transcript.error.action_required.text"]) + timeout_bg = _bg(style_rules["transcript.error.timeout_error.prefix"]) + connection_bg = _bg(style_rules["transcript.error.connection_error.prefix"]) + transport_bg = _bg(style_rules["transcript.error.transport_error.prefix"]) + command_bg = _bg(style_rules["transcript.error.command_error.prefix"]) + + self.assertIsNotNone(compile_bg) + self.assertIsNotNone(action_bg) + self.assertNotEqual(action_bg, compile_bg) + self.assertIn("bold", style_rules["transcript.error.action_required.text"].split()) + self.assertIsNotNone(timeout_bg) + self.assertIsNotNone(connection_bg) + self.assertIsNotNone(transport_bg) + self.assertIsNotNone(command_bg) + + self.assertEqual( + len({compile_bg, timeout_bg, connection_bg, transport_bg, command_bg}), + 5, + "Each transcript error category should have a distinct background style category", + ) + + +class TranscriptRenderingHelpersTests(unittest.TestCase): + def test_render_transcript_input_block_uses_input_styles(self): + self.assertEqual( + session_ui.render_transcript_input_block("Debug.Log(1);", "2026-04-04T12:34:56.000Z"), + [ + ("class:transcript.timestamp", "[12:34:56] "), + ("class:transcript.input.prefix", "> "), + ("class:transcript.input.text", "Debug.Log(1);"), + ], + ) + + def test_render_transcript_result_block_uses_result_styles(self): + self.assertEqual( + session_ui.render_transcript_result_block("1", "2026-04-04T12:34:57.000Z"), + [ + ("class:transcript.timestamp", "[12:34:57] "), + ("class:transcript.result.prefix", "< "), + ("class:transcript.result.text", "1"), + ], + ) + + def test_render_transcript_round_separator_uses_separator_style(self): + self.assertEqual( + session_ui.render_transcript_round_separator(12), + [ + ("class:transcript.separator", session_ui.ROUND_SEPARATOR_CHAR * 12), + ], + ) + + def test_render_input_divider_uses_full_width_fill(self): + self.assertEqual( + session_ui.render_input_divider(8), + [ + ("class:input.divider", session_ui.INPUT_DIVIDER_CHAR * 8), + ], + ) + + def test_render_transcript_error_block_uses_error_kind_style_keys(self): + self.assertEqual( + session_ui.render_transcript_error_block("compile_error", "CS1002", "2026-04-04T12:34:58.000Z"), + [ + ("class:transcript.timestamp", "[12:34:58] "), + ("class:transcript.error.compile_error.prefix", "! "), + ("class:transcript.error.compile_error.text", "CS1002"), + ], + ) + self.assertEqual( + session_ui.render_transcript_error_block("timeout_error", "Timed out", "2026-04-04T12:34:59.000Z"), + [ + ("class:transcript.timestamp", "[12:34:59] "), + ("class:transcript.error.timeout_error.prefix", "! "), + ("class:transcript.error.timeout_error.text", "Timed out"), + ], + ) + self.assertEqual( + session_ui.render_transcript_error_block("connection_error", "Connection refused", "2026-04-04T12:35:00.000Z"), + [ + ("class:transcript.timestamp", "[12:35:00] "), + ("class:transcript.error.connection_error.prefix", "! "), + ("class:transcript.error.connection_error.text", "Connection refused"), + ], + ) + self.assertEqual( + session_ui.render_transcript_error_block("transport_error", "Malformed response", "2026-04-04T12:35:01.000Z"), + [ + ("class:transcript.timestamp", "[12:35:01] "), + ("class:transcript.error.transport_error.prefix", "! "), + ("class:transcript.error.transport_error.text", "Malformed response"), + ], + ) + self.assertEqual( + session_ui.render_transcript_error_block("command_error", "Unknown action", "2026-04-04T12:35:02.000Z"), + [ + ("class:transcript.timestamp", "[12:35:02] "), + ("class:transcript.error.command_error.prefix", "! "), + ("class:transcript.error.command_error.text", "Unknown action"), + ], + ) + + +class ResultTranscriptWiringRegressionTests(unittest.TestCase): + def test_try_process_command_expression_returns_false_for_escaped_identifier(self): + previous_session = repl.session + previous_request_command = client.request_command + + class _AppSpy: + def __init__(self): + self.invalidate_calls = 0 + + def invalidate(self): + self.invalidate_calls += 1 + + class _SessionSpy: + def __init__(self): + self.transcript_state = repl.TranscriptState() + self.app = _AppSpy() + + requests = [] + repl.session = _SessionSpy() + client.request_command = lambda *_args, **_kwargs: requests.append((_args, _kwargs)) + try: + handled = repl.try_process_command_expression("@class") + finally: + current_session = repl.session + client.request_command = previous_request_command + repl.session = previous_session + + self.assertFalse(handled) + self.assertEqual(requests, []) + self.assertEqual(current_session.transcript_state.entries, []) + + def test_try_process_command_expression_appends_transcript_entry_without_type_mismatch(self): + previous_session = repl.session + previous_request_command = client.request_command + + class _AppSpy: + def __init__(self): + self.invalidate_calls = 0 + + def invalidate(self): + self.invalidate_calls += 1 + + class _SessionSpy: + def __init__(self): + self.transcript_state = repl.TranscriptState() + self.app = _AppSpy() + + repl.session = _SessionSpy() + client.request_command = lambda *_args, **_kwargs: { + "ok": False, + "stage": "command", + "type": "command_error", + "summary": "Unknown action", + "data": {"text": "Unknown action\n"}, + } + try: + handled = repl.try_process_command_expression("@game.pause()") + finally: + current_session = repl.session + client.request_command = previous_request_command + repl.session = previous_session + + self.assertTrue(handled) + self.assertEqual(len(current_session.transcript_state.entries), 1) + entry = current_session.transcript_state.entries[0] + self.assertEqual(entry.entry_type, "result") + self.assertEqual(entry.error_kind, "command_error") + + def test_execute_repl_snippet_appends_transcript_entry_without_double_building(self): + previous_session = repl.session + previous_runtime_mode = config.runtime_mode + previous_execute_editor_request = client.execute_editor_request + + class _AppSpy: + def __init__(self): + self.invalidate_calls = 0 + + def invalidate(self): + self.invalidate_calls += 1 + + class _SessionSpy: + def __init__(self): + self.transcript_state = repl.TranscriptState() + self.app = _AppSpy() + + repl.session = _SessionSpy() + config.runtime_mode = False + + result_payload = { + "ok": True, + "stage": "execute", + "type": "", + "summary": "ok", + "data": {"text": "42\n"}, + } + + client.execute_editor_request = ( + lambda _message, _cmd_id, reset=False, invalidate_completion=None: result_payload + ) + + try: + repl.execute_repl_snippet("1+41", reset=False) + finally: + current_session = repl.session + client.execute_editor_request = previous_execute_editor_request + config.runtime_mode = previous_runtime_mode + repl.session = previous_session + + self.assertEqual(len(current_session.transcript_state.entries), 1) + entry = current_session.transcript_state.entries[0] + self.assertEqual(entry.entry_type, "result") + self.assertTrue(entry.ok) + self.assertEqual(entry.text, "42\n") + + +class TitleAndToolbarTextTests(unittest.TestCase): + def test_editor_title_uses_editor_ip(self): + with ReplStateOverride( + runtime_mode=False, + enableml=1, + enable_completion=True, + ip="127.0.0.1", + port=14500, + compile_ip="127.0.0.1", + compile_port=14500, + runtime_ip="127.0.0.1", + runtime_port=15500, + ): + title = repl._build_terminal_title() + + self.assertEqual(title, "c# REPL/127.0.0.1:14500") + + def test_runtime_title_uses_runtime_ip(self): + with ReplStateOverride( + runtime_mode=True, + enableml=1, + enable_completion=True, + ip="127.0.0.1", + port=14500, + compile_ip="10.0.0.2", + compile_port=14500, + runtime_ip="10.0.0.9", + runtime_port=15500, + ): + title = repl._build_terminal_title() + + self.assertEqual(title, "c# REPL/10.0.0.9:15500") + + def test_application_shell_uses_custom_bottom_anchored_layout(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = ApplicationSpy + try: + session = repl.ensure_prompt_session() + finally: + repl.Application = previous_application + repl.session = previous_session + + self.assertIsNotNone( + session.app.layout, + "Application shell should build a prompt_toolkit Layout instance", + ) + self.assertIsNotNone( + session.transcript_control, + "Application shell should expose transcript control for transcript rendering", + ) + self.assertIsNotNone( + session.transcript_window, + "Application shell should expose transcript window for transcript rendering", + ) + self.assertIsNotNone( + session.input_control, + "Application shell should expose input control for the editable input area", + ) + self.assertIsNotNone( + session.input_divider, + "Application shell should render a visible divider between transcript history and input area", + ) + self.assertEqual( + session.footer_line_1_left.text(), + session_ui.build_footer_status_left_text(session.default_buffer, repl._pending_quit_confirmation, repl.enableml), + "First footer line left segment should stay wired to toolbar hint text", + ) + self.assertEqual( + session.footer_line_1_right.text(), + session_ui.build_footer_status_right_text(repl.enableml, repl.enable_completion), + "First footer line right segment should show completion state", + ) + self.assertEqual( + session.footer_line_2_left.text(), + session_ui.build_footer_common_shortcuts_text(), + "Second footer line left segment should render common shortcuts", + ) + self.assertEqual( + session.footer_line_2_right.text(), + session_ui.build_footer_session_text(config, repl.cmd_id), + "Second footer line right segment should render session metadata via session_ui helper", + ) + + def test_footer_status_left_uses_default_submit_shortcuts(self): + left = session_ui.build_footer_status_left_text( + FakeToolbarBuffer(), pending_quit_confirmation=False, enableml=1, searching=False + ) + + self.assertEqual( + left, + [ + ("class:footer.status.left", "[/] commands · [@] actions"), + ], + ) + + def test_footer_status_right_shows_completion_state(self): + right = session_ui.build_footer_status_right_text(enableml=1, enable_completion=True) + + self.assertEqual( + right, + [ + ("class:footer.status.right", "● completion"), + ], + ) + + def test_footer_common_shortcuts_text_lists_shared_actions(self): + self.assertEqual( + session_ui.build_footer_common_shortcuts_text(), + [ + ("class:footer.status.left", "[Ctrl+Enter] newline · [Ctrl+R] history"), + ], + ) + + def test_footer_status_left_switches_when_completion_menu_is_visible(self): + left = session_ui.build_footer_status_left_text( + FakeToolbarBuffer(semantic_texts=("WriteLine",), selected_index=0), + pending_quit_confirmation=False, + enableml=1, + searching=False, + ) + + self.assertEqual( + left, + [ + ("class:footer.status.left", "[↑↓] select · [Tab] accept · [/] commands · [@] actions"), + ], + ) + + def test_prompt_message_uses_minimal_left_prompt(self): + prompt = repl._build_prompt_message() + + self.assertEqual( + prompt, + [ + ("class:prompt.sep", "> "), + ], + ) + + def test_prompt_continuation_uses_minimal_left_marker(self): + continuation = repl._build_prompt_continuation(0, 0, 0) + + self.assertEqual( + continuation, + [ + ("class:prompt.sep", "· "), + ], + ) + + def test_footer_status_left_keeps_submit_guidance_when_buffer_has_text(self): + left = session_ui.build_footer_status_left_text( + FakeToolbarBuffer(text="Debug"), pending_quit_confirmation=False, enableml=1, searching=False + ) + + self.assertEqual( + left, + [ + ("class:footer.status.left", "[Ctrl+C] clear"), + ], + ) + + def test_footer_status_right_reflects_completion_off(self): + right = session_ui.build_footer_status_right_text(enableml=1, enable_completion=False) + + self.assertEqual( + right, + [ + ("class:footer.status.right", "○ completion"), + ], + ) + + def test_footer_status_left_shows_quit_confirmation_when_pending(self): + left = session_ui.build_footer_status_left_text( + FakeToolbarBuffer(), pending_quit_confirmation=True, enableml=1, searching=False + ) + + self.assertEqual( + left, + [ + ("class:footer.status.left", "[Ctrl+C] quit · [Any key] cancel"), + ], + ) + + def test_footer_status_left_shows_search_shortcuts_when_searching(self): + left = session_ui.build_footer_status_left_text( + FakeToolbarBuffer(), pending_quit_confirmation=False, enableml=1, searching=True + ) + + self.assertEqual( + left, + [ + ("class:footer.status.left", "[Enter] accept · [↑↓] prev/next · [Ctrl+C] cancel"), + ], + ) + + def test_help_info_does_not_print_to_stdout(self): + import io + from contextlib import redirect_stdout + + stream = io.StringIO() + with redirect_stdout(stream): + repl.print_help_info() + + self.assertEqual(stream.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_terminal_layout.py b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_terminal_layout.py new file mode 100644 index 0000000..7f5562d --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_terminal_layout.py @@ -0,0 +1,1648 @@ +import asyncio +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +SCRIPT_ROOT = os.path.dirname(os.path.abspath(__file__)) +CONSOLE_CLIENT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_ROOT)) +SITE_PACKAGES_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "site-packages") +_ADDED_SITE_PACKAGES_PATH = False +CORE_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "csharpconsole_core") +_ADDED_CORE_PATH = False + +if CONSOLE_CLIENT_ROOT not in sys.path: + sys.path.insert(0, CONSOLE_CLIENT_ROOT) + +if SITE_PACKAGES_PATH not in sys.path: + sys.path.insert(0, SITE_PACKAGES_PATH) + _ADDED_SITE_PACKAGES_PATH = True + +if CORE_PATH not in sys.path: + sys.path.insert(0, CORE_PATH) + _ADDED_CORE_PATH = True + +from prompt_toolkit.application.current import create_app_session +from prompt_toolkit.data_structures import Point, Size +from prompt_toolkit.document import Document +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType +from prompt_toolkit.output import DummyOutput + +import csharp_repl_core as repl +from repl import builtins, output +from repl import loop +from repl import scroll_router +from repl import session_ui +from repl import viewport_policy +from repl.transcript import TranscriptEntry, TranscriptState +from repl.transcript_control import TranscriptControl + + +def _extract_text_from_data(data): + return (data or {}).get("text", "") + + +class _ApplicationSpy: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.layout = kwargs.get("layout") + self.style = kwargs.get("style") + self.output = type("Output", (), {"get_size": lambda _self: type("Size", (), {"columns": 80})()})() + self.invalidate_calls = 0 + + def invalidate(self): + self.invalidate_calls += 1 + + def run(self): + return None + + +class _SizedDummyOutput(DummyOutput): + def __init__(self, columns=80, rows=24): + super().__init__() + self._size = Size(rows=rows, columns=columns) + + def get_size(self): + return self._size + + +class ReplApplicationShellViewportWiringTests(unittest.TestCase): + def _create_session(self): + previous_session = repl.session + previous_application = repl.Application + repl.session = None + repl.Application = _ApplicationSpy + self.addCleanup(setattr, repl, "Application", previous_application) + self.addCleanup(setattr, repl, "session", previous_session) + return repl.ensure_prompt_session() + + def test_submit_current_buffer_exits_with_text_in_prompt_mode_without_interactive_callback(self): + session = self._create_session() + session.default_buffer.text = "Debug.Log(123);" + + class _EventApp: + def __init__(self, buffer): + self.current_buffer = buffer + self.exit_calls = [] + + def exit(self, result=None): + self.exit_calls.append(result) + + class _Event: + def __init__(self, app): + self.app = app + + event_app = _EventApp(session.default_buffer) + event = _Event(event_app) + + repl._submit_current_buffer(event) + + self.assertEqual(event_app.exit_calls, ["Debug.Log(123);"]) + + def test_get_input_height_uses_available_input_width(self): + session = self._create_session() + session.default_buffer.text = "abcdef" + + previous_compute = viewport_policy.compute_input_height + recorded = [] + + def _spy(document_text, available_width=None, max_visible_lines=8): + recorded.append((document_text, available_width, max_visible_lines)) + return previous_compute( + document_text, + available_width=available_width, + max_visible_lines=max_visible_lines, + ) + + viewport_policy.compute_input_height = _spy + self.addCleanup(setattr, viewport_policy, "compute_input_height", previous_compute) + + try: + session._get_available_width = lambda: 6 + height = session._get_input_height() + finally: + session._get_available_width = repl.ReplApplicationShell._get_available_width.__get__(session, repl.ReplApplicationShell) + + self.assertEqual(height.preferred, 2) + self.assertTrue(recorded) + self.assertEqual(recorded[-1][0], "abcdef") + self.assertEqual(recorded[-1][1], 4) + + def test_handle_input_text_changed_pins_transcript_after_visible_height_change_and_invalidates_app(self): + session = self._create_session() + + class _RenderInfo: + vertical_scroll = 12 + content_height = 20 + window_height = 8 + + session.transcript_window.render_info = _RenderInfo() + session.transcript_window.vertical_scroll = 12 + session.default_buffer.text = "line1\nline2" + + previous_compute_visible = viewport_policy.compute_input_visible_lines + previous_is_bottom = viewport_policy.is_transcript_at_bottom + previous_pin_bottom = viewport_policy.pin_transcript_to_bottom + + visible_calls = [] + bottom_checks = [] + pin_calls = [] + + def _compute_visible(document_text, available_width=None, max_visible_lines=8): + visible_calls.append((document_text, available_width, max_visible_lines)) + return 2 + + def _is_bottom(window): + bottom_checks.append(window) + return True + + def _pin_bottom(window): + pin_calls.append(window) + + viewport_policy.compute_input_visible_lines = _compute_visible + viewport_policy.is_transcript_at_bottom = _is_bottom + viewport_policy.pin_transcript_to_bottom = _pin_bottom + self.addCleanup(setattr, viewport_policy, "compute_input_visible_lines", previous_compute_visible) + self.addCleanup(setattr, viewport_policy, "is_transcript_at_bottom", previous_is_bottom) + self.addCleanup(setattr, viewport_policy, "pin_transcript_to_bottom", previous_pin_bottom) + + session._last_input_visible_lines = 1 + session._get_available_width = lambda: 8 + session.app.invalidate_calls = 0 + + session._handle_input_text_changed(session.default_buffer) + + self.assertEqual(session.app.invalidate_calls, 1) + self.assertEqual(session._last_input_visible_lines, 2) + self.assertEqual(len(bottom_checks), 1) + self.assertEqual(pin_calls, [session.transcript_window]) + self.assertTrue(visible_calls) + self.assertEqual(visible_calls[-1][1], 6) + + def test_application_merges_key_bindings_with_custom_handlers_before_default_mouse(self): + previous_merge = repl.merge_key_bindings + recorded_sequences = [] + + def _merge_spy(sequence): + recorded_sequences.append(sequence) + return previous_merge(sequence) + + repl.merge_key_bindings = _merge_spy + self.addCleanup(setattr, repl, "merge_key_bindings", previous_merge) + + self._create_session() + + self.assertTrue(recorded_sequences) + self.assertIs(recorded_sequences[-1][0], repl.bindings) + self.assertIs(recorded_sequences[-1][1], repl.default_mouse_bindings) + + def test_append_input_transcript_pins_to_bottom(self): + session = self._create_session() + + previous_pin_bottom = viewport_policy.pin_transcript_to_bottom + pin_calls = [] + + def _pin_bottom(window): + pin_calls.append(window) + + viewport_policy.pin_transcript_to_bottom = _pin_bottom + self.addCleanup(setattr, viewport_policy, "pin_transcript_to_bottom", previous_pin_bottom) + + session.app.invalidate_calls = 0 + session.append_input_transcript("Debug.Log(1);") + + self.assertEqual(pin_calls, [session.transcript_window]) + self.assertEqual(session.app.invalidate_calls, 1) + self.assertEqual(session.transcript_state.entries[-1].entry_type, "input") + + def test_append_result_transcript_entry_pins_to_bottom(self): + session = self._create_session() + + previous_pin_bottom = viewport_policy.pin_transcript_to_bottom + pin_calls = [] + + def _pin_bottom(window): + pin_calls.append(window) + + viewport_policy.pin_transcript_to_bottom = _pin_bottom + self.addCleanup(setattr, viewport_policy, "pin_transcript_to_bottom", previous_pin_bottom) + + session.app.invalidate_calls = 0 + entry = TranscriptEntry(entry_type="result", ok=True, text="1", summary="ok") + session.append_result_transcript_entry(entry) + + self.assertEqual(pin_calls, [session.transcript_window]) + self.assertEqual(session.app.invalidate_calls, 1) + self.assertIs(session.transcript_state.entries[-1], entry) + + def test_handle_submitted_message_resets_buffer_and_refocuses_input(self): + session = self._create_session() + + submitted = [] + session._on_submit = lambda text: submitted.append(text) + session.default_buffer.text = "line1\nline2" + session._last_input_visible_lines = 4 + session.app.invalidate_calls = 0 + + class _LayoutStub: + def __init__(self): + self.focus_calls = [] + + def focus(self, target): + self.focus_calls.append(target) + + class _EventAppStub: + def __init__(self): + self.layout = _LayoutStub() + + class _EventStub: + def __init__(self): + self.app = _EventAppStub() + + event = _EventStub() + + session.handle_submitted_message("Debug.Log(1);", event) + + self.assertEqual(submitted, ["Debug.Log(1);"]) + self.assertEqual(session.default_buffer.text, "") + self.assertEqual(session._last_input_visible_lines, 1) + self.assertEqual(event.app.layout.focus_calls, [session.input_control]) + self.assertEqual(session.app.invalidate_calls, 1) + + def test_after_render_delays_external_open_in_background_task(self): + session = self._create_session() + previous_delay = repl.EXTERNAL_OPEN_DELAY_SECONDS + scheduled = [] + open_calls = [] + + def _create_background_task(coro): + scheduled.append(coro) + return coro + + self.addCleanup(setattr, repl, "EXTERNAL_OPEN_DELAY_SECONDS", previous_delay) + repl.EXTERNAL_OPEN_DELAY_SECONDS = 0 + session.app.create_background_task = _create_background_task + session.queue_external_open(lambda: open_calls.append("open")) + + session._handle_after_render(session.app) + + self.assertEqual(open_calls, []) + self.assertEqual(len(scheduled), 1) + + asyncio.run(scheduled[0]) + + self.assertEqual(open_calls, ["open"]) + self.assertFalse(session._external_open_task_active) + + +class PromptToolkitIntegrationRegressionTests(unittest.TestCase): + def test_transcript_render_info_counts_wrapped_lines_as_scrollable_height(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text="X" * 120, summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + self.assertIsNotNone(session.transcript_window.render_info) + self.assertGreater( + session.transcript_window.render_info.content_height, + session.transcript_window.render_info.window_height, + "Wrapped transcript content should produce scrollable height in the real prompt_toolkit Window render info", + ) + + asyncio.run(_run_test()) + + def test_input_window_height_matches_wrapped_visible_lines_without_extra_growth(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=12)): + session = repl.ensure_prompt_session() + session.default_buffer.set_document(Document("X" * 60, cursor_position=60), bypass_readonly=True) + session.app.renderer.render(session.app, session.app.layout) + + root = session.app.layout.container.content + main_block = root.children[0].content + input_row = next( + container + for container in main_block.children + if any( + getattr(child, "content", None) is session.input_control + for child in getattr(container, "children", []) + ) + ) + input_window = next( + child for child in input_row.children if getattr(child, "content", None) is session.input_control + ) + prompt_window = next(child for child in input_row.children if child is not input_window) + + self.assertEqual(session._get_input_height().preferred, 3) + self.assertEqual( + input_window.render_info.window_height, + 3, + "Input window should match wrapped visible line count instead of stretching taller than its content", + ) + self.assertEqual( + prompt_window.render_info.window_height, + 3, + "Prompt column should stay aligned with the input window height", + ) + + asyncio.run(_run_test()) + + def test_theme_preview_keeps_history_visible_in_standard_terminal(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=100, rows=24)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + session.transcript_state.append_input("Debug.Log(42);") + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text="42", summary="ok") + ) + session.default_buffer.set_document( + Document("/theme material", cursor_position=len("/theme material")), + bypass_readonly=True, + ) + session.app.renderer.render(session.app, session.app.layout) + + self.assertTrue(session.theme_preview_container.filter()) + self.assertGreaterEqual( + session.transcript_window.render_info.window_height, + 8, + "Compact theme preview should leave enough rows to read transcript history in a 24-row terminal", + ) + screen = session.app.renderer._last_screen + rendered_rows = [ + "".join(screen.data_buffer[row][column].char for column in range(100)).rstrip() + for row in range(24) + ] + preview_row = next(index for index, text in enumerate(rendered_rows) if "[theme preview]" in text) + history_row = next(index for index, text in enumerate(rendered_rows) if "Debug.Log(42);" in text) + self.assertLess( + preview_row, + history_row, + "Theme preview should stay above transcript history so an upward completion menu cannot cover it", + ) + + asyncio.run(_run_test()) + + def test_transcript_scroll_persists_after_scroll_down_and_render(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text="X" * 120, summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + self.assertGreater( + session.transcript_window.render_info.content_height, + session.transcript_window.render_info.window_height, + ) + + session.scroll_transcript_window_down() + session.app.renderer.render(session.app, session.app.layout) + + self.assertGreater( + session.transcript_window.vertical_scroll, + 0, + "Transcript scroll position should persist after wheel/down scrolling instead of being reset on render", + ) + + asyncio.run(_run_test()) + + def test_transcript_scroll_can_continue_across_many_wheel_up_steps(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + for index in range(6): + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text=(str(index) + "-") * 40, summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + initial_scroll = session.transcript_window.vertical_scroll + self.assertGreater(initial_scroll, 0) + + for _ in range(8): + session.scroll_transcript_window_up() + session.app.renderer.render(session.app, session.app.layout) + + self.assertLess( + session.transcript_window.vertical_scroll, + initial_scroll - 4, + "Transcript should keep scrolling upward across repeated wheel events instead of getting stuck after a few steps", + ) + + asyncio.run(_run_test()) + + def test_transcript_wheel_up_moves_to_previous_round_start(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + for index in range(8): + session.transcript_state.append_input(f"cmd {index} {'X' * 30}") + session.transcript_state.append_result( + TranscriptEntry( + entry_type="result", + ok=True, + text=f"result {index} {'Y' * 40}", + summary="ok", + ) + ) + session.app.renderer.render(session.app, session.app.layout) + + initial_scroll = session.transcript_window.vertical_scroll + round_starts = [line for line in session.transcript_control._round_starts if line < initial_scroll] + self.assertTrue(round_starts) + previous_round_start = round_starts[-1] + + session.scroll_transcript_window_up() + session.app.renderer.render(session.app, session.app.layout) + + self.assertEqual( + session.transcript_window.vertical_scroll, + previous_round_start, + "Wheel-up should jump to the previous round start so fully hidden rounds can re-enter view immediately", + ) + + asyncio.run(_run_test()) + + def test_transcript_wheel_up_can_reach_leading_result_only_round(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text="bootstrap " + ("Z" * 40), summary="ok") + ) + for index in range(1, 11): + session.transcript_state.append_input(f"cmd {index}") + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text=f"result {index}", summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + for _ in range(20): + session.scroll_transcript_window_up() + session.app.renderer.render(session.app, session.app.layout) + + self.assertEqual( + session.transcript_window.vertical_scroll, + 0, + "Scrolling up repeatedly should reach the very first leading result-only round", + ) + + asyncio.run(_run_test()) + + def test_transcript_wheel_down_returns_to_tail_after_round_navigation(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + for index in range(1, 11): + session.transcript_state.append_input(f"cmd {index} {'X' * 20}") + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text=f"result {index} {'Y' * 20}", summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + tail_scroll = session.transcript_window.vertical_scroll + for _ in range(5): + session.scroll_transcript_window_up() + session.app.renderer.render(session.app, session.app.layout) + + for _ in range(10): + session.scroll_transcript_window_down() + session.app.renderer.render(session.app, session.app.layout) + + self.assertEqual( + session.transcript_window.vertical_scroll, + tail_scroll, + "Scrolling back down should return to the original tail position instead of stopping at an intermediate round", + ) + + asyncio.run(_run_test()) + + def test_input_height_change_keeps_transcript_pinned_after_scrolling_back_to_tail(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + for index in range(1, 11): + session.transcript_state.append_input(f"cmd {index} {'X' * 20}") + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text=f"result {index} {'Y' * 20}", summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + for _ in range(5): + session.scroll_transcript_window_up() + session.app.renderer.render(session.app, session.app.layout) + for _ in range(10): + session.scroll_transcript_window_down() + session.app.renderer.render(session.app, session.app.layout) + + tail_scroll = session.transcript_window.vertical_scroll + session.default_buffer.set_document(Document("X" * 60, cursor_position=60), bypass_readonly=True) + session._handle_input_text_changed(session.default_buffer) + session.app.renderer.render(session.app, session.app.layout) + + self.assertEqual( + session.transcript_window.vertical_scroll, + session.transcript_control.get_vertical_scroll(), + ) + self.assertGreaterEqual( + session.transcript_window.vertical_scroll, + tail_scroll, + "After returning to tail, input height changes should keep transcript pinned to the bottom", + ) + + asyncio.run(_run_test()) + + def test_transcript_exact_bootstrap_and_1_to_10_scenario_returns_to_original_tail(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text="bootstrap-only", summary="ok") + ) + for index in range(1, 11): + session.transcript_state.append_input(str(index)) + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text=f"res {index}", summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + original_tail_scroll = session.transcript_window.vertical_scroll + + for _ in range(20): + session.scroll_transcript_window_up() + session.app.renderer.render(session.app, session.app.layout) + + for _ in range(20): + session.scroll_transcript_window_down() + session.app.renderer.render(session.app, session.app.layout) + + self.assertEqual( + session.transcript_window.vertical_scroll, + original_tail_scroll, + "Bootstrap + 1..10 scenario should return to the exact original tail position after scrolling back down", + ) + + asyncio.run(_run_test()) + + def test_new_message_while_browsing_history_repins_transcript_to_tail(self): + async def _run_test(): + previous_session = repl.session + repl.session = None + self.addCleanup(setattr, repl, "session", previous_session) + + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=_SizedDummyOutput(columns=24, rows=10)): + session = repl.ensure_prompt_session() + session.transcript_state.clear() + for index in range(1, 11): + session.transcript_state.append_input(str(index)) + session.transcript_state.append_result( + TranscriptEntry(entry_type="result", ok=True, text=f"res {index}", summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + tail_scroll = session.transcript_window.vertical_scroll + + for _ in range(5): + session.scroll_transcript_window_up() + session.app.renderer.render(session.app, session.app.layout) + + session.append_result_transcript_entry( + TranscriptEntry(entry_type="result", ok=True, text="new tail result", summary="ok") + ) + session.app.renderer.render(session.app, session.app.layout) + + self.assertGreaterEqual( + session.transcript_window.vertical_scroll, + tail_scroll, + "A new incoming message should repin transcript browsing back to the tail", + ) + + asyncio.run(_run_test()) + + +class TranscriptControlMouseHandlingTests(unittest.TestCase): + def test_transcript_control_remains_non_focusable_for_normal_typing(self): + control = TranscriptControl(TranscriptState()) + + self.assertFalse(control.is_focusable()) + + def test_split_submission_action_is_rendered_as_separate_highlighted_lines(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_result( + TranscriptEntry( + entry_type="result", + ok=False, + error_kind="compile_error", + text=( + "(1,1): error CS0433: The type exists in both assemblies" + "\n\n[REPL ACTION REQUIRED]" + "\nSplit this code into two REPL submissions:" + "\n 1. Submit the expression that uses the ambiguous type first." + "\n 2. Submit the non-public member access separately afterward." + "\n\nReason: ignoring accessibility exposed same-named types." + ), + summary="Compile failed", + created_at="2026-04-05T12:34:56.000Z", + ) + ) + + content = control.create_content(width=160, height=20) + rendered_lines = [content.get_line(index) for index in range(content.line_count)] + action_lines = [ + fragments + for fragments in rendered_lines + if any("[REPL ACTION REQUIRED]" in text or "Submit the " in text for _style, text, *_rest in fragments) + ] + reason_line = next( + fragments + for fragments in rendered_lines + if any("Reason:" in text for _style, text, *_rest in fragments) + ) + + self.assertEqual(len(action_lines), 3) + self.assertTrue( + all( + any(style == "class:transcript.error.action_required.text" for style, _text, *_rest in fragments) + for fragments in action_lines + ) + ) + self.assertTrue( + any(style == "class:transcript.error.compile_error.text" for style, _text, *_rest in reason_line) + ) + + def test_successful_accessibility_fallback_notice_is_highlighted_above_result(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_result( + TranscriptEntry( + entry_type="result", + ok=True, + text=( + "[REPL NOTICE]\n" + "Symbol conflict detected: this submission was recompiled with standard C# accessibility.\n" + "Non-public member access is unavailable in this submission.\n" + "Later submissions still try the REPL accessibility bypass first.\n\n" + "ManualAmbiguityProbe.Collision" + ), + summary="OK", + created_at="2026-04-05T12:34:56.000Z", + ) + ) + + content = control.create_content(width=160, height=20) + rendered_lines = [content.get_line(index) for index in range(content.line_count)] + notice_lines = rendered_lines[:4] + result_line = rendered_lines[-1] + + self.assertTrue( + all( + any(style == "class:transcript.notice.accessibility.text" for style, _text, *_rest in fragments) + for fragments in notice_lines + ) + ) + self.assertTrue( + any( + style == "class:transcript.result.text" and "ManualAmbiguityProbe.Collision" in text + for style, text, *_rest in result_line + ) + ) + + def test_multiline_input_continuation_aligns_under_content_column(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append( + TranscriptEntry( + entry_type="input", + text="line1\nline2", + created_at="2026-04-05T12:34:56.000Z", + ) + ) + + content = control.create_content(width=80, height=10) + + self.assertEqual( + "".join(text for _style, text in content.get_line(0)), + "[12:34:56] > line1", + ) + self.assertEqual( + "".join(text for _style, text in content.get_line(1)), + " line2", + ) + + def test_multiline_result_continuation_aligns_under_content_column(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_result( + TranscriptEntry( + entry_type="result", + ok=True, + text="line1\nline2", + summary="ok", + created_at="2026-04-05T12:34:56.000Z", + ) + ) + + content = control.create_content(width=80, height=10) + + self.assertEqual( + "".join(text for _style, text in content.get_line(0)), + "[12:34:56] < line1", + ) + self.assertEqual( + "".join(text for _style, text in content.get_line(1)), + " line2", + ) + + def test_result_trailing_newline_does_not_render_extra_blank_continuation_line(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_result( + TranscriptEntry( + entry_type="result", + ok=True, + text="line1\n", + summary="ok", + created_at="2026-04-05T12:34:56.000Z", + ) + ) + + content = control.create_content(width=80, height=10) + + self.assertEqual(content.line_count, 1) + self.assertEqual( + "".join(text for _style, text in content.get_line(0)), + "[12:34:56] < line1", + ) + + def test_mouse_drag_selects_transcript_text_for_copy(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_result(TranscriptEntry(entry_type="result", ok=True, text="alpha beta", summary="ok")) + control.create_content(width=80, height=10) + + line_text = control._line_plain_texts[0] + start_x = line_text.index("alpha") + end_x = line_text.index("beta") + len("beta") + + control.mouse_handler( + MouseEvent( + position=Point(x=start_x, y=0), + event_type=MouseEventType.MOUSE_DOWN, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=end_x, y=0), + event_type=MouseEventType.MOUSE_MOVE, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=end_x, y=0), + event_type=MouseEventType.MOUSE_UP, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + + clipboard_data = control.copy_selection() + + self.assertEqual(clipboard_data.text, "alpha beta") + self.assertIsNone(control.selection_state) + + def test_mouse_drag_to_line_end_includes_last_character(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_result(TranscriptEntry(entry_type="result", ok=True, text="abc", summary="ok")) + control.create_content(width=80, height=10) + + line_text = control._line_plain_texts[0] + start_x = line_text.index("a") + last_x = len(line_text) - 1 + + control.mouse_handler( + MouseEvent( + position=Point(x=start_x, y=0), + event_type=MouseEventType.MOUSE_DOWN, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=last_x, y=0), + event_type=MouseEventType.MOUSE_MOVE, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=last_x, y=0), + event_type=MouseEventType.MOUSE_UP, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + + clipboard_data = control.copy_selection() + + self.assertEqual(clipboard_data.text, "abc") + + def test_mouse_drag_selection_persists_when_move_and_up_report_no_button(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_result(TranscriptEntry(entry_type="result", ok=True, text="alpha beta", summary="ok")) + control.create_content(width=80, height=10) + + line_text = control._line_plain_texts[0] + start_x = line_text.index("alpha") + end_x = line_text.index("beta") + len("beta") + + control.mouse_handler( + MouseEvent( + position=Point(x=start_x, y=0), + event_type=MouseEventType.MOUSE_DOWN, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=end_x, y=0), + event_type=MouseEventType.MOUSE_MOVE, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=end_x, y=0), + event_type=MouseEventType.MOUSE_UP, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + ) + + clipboard_data = control.copy_selection() + + self.assertEqual(clipboard_data.text, "alpha beta") + + def test_mouse_drag_across_blank_separator_line_keeps_selection(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_input("first") + state.append_result(TranscriptEntry(entry_type="result", ok=True, text="second", summary="ok")) + control.create_content(width=80, height=10) + + first_line_text = control._line_plain_texts[0] + result_line_index = 2 + result_line_text = control._line_plain_texts[result_line_index] + + start_x = first_line_text.index("first") + end_x = result_line_text.index("second") + len("second") + + control.mouse_handler( + MouseEvent( + position=Point(x=start_x, y=0), + event_type=MouseEventType.MOUSE_DOWN, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=0, y=1), + event_type=MouseEventType.MOUSE_MOVE, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=end_x, y=result_line_index), + event_type=MouseEventType.MOUSE_MOVE, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + ) + control.mouse_handler( + MouseEvent( + position=Point(x=end_x, y=result_line_index), + event_type=MouseEventType.MOUSE_UP, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + ) + + clipboard_data = control.copy_selection() + + self.assertIn("first", clipboard_data.text) + self.assertIn("second", clipboard_data.text) + self.assertIn("\n\n", clipboard_data.text) + + def test_completed_round_renders_trailing_separator_before_next_input_exists(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_input("first") + state.append_result(TranscriptEntry(entry_type="result", ok=True, text="done-1", summary="ok")) + + control.create_content(width=80, height=12) + + self.assertIn("done-1", control._line_plain_texts[2]) + self.assertEqual(control._line_plain_texts[3], session_ui.ROUND_SEPARATOR_CHAR * 80) + self.assertEqual(len(control._line_plain_texts), 4) + + def test_round_separator_has_no_blank_line_before_or_after(self): + state = TranscriptState() + control = TranscriptControl(state) + state.append_input("first") + state.append_result(TranscriptEntry(entry_type="result", ok=True, text="done-1", summary="ok")) + state.append_input("second") + state.append_result(TranscriptEntry(entry_type="result", ok=True, text="done-2", summary="ok")) + + control.create_content(width=80, height=12) + + separator = session_ui.ROUND_SEPARATOR_CHAR * 80 + separator_indexes = [ + index for index, line in enumerate(control._line_plain_texts) if line == separator + ] + self.assertEqual(separator_indexes, [3, 7]) + for index in separator_indexes: + if index > 0: + self.assertNotEqual(control._line_plain_texts[index - 1], "") + if index + 1 < len(control._line_plain_texts): + self.assertNotEqual(control._line_plain_texts[index + 1], "") + + def test_mouse_scroll_up_moves_to_previous_round_target(self): + control = TranscriptControl(TranscriptState()) + state = control._transcript_state + for index in range(1, 6): + state.append_input(f"cmd {index}") + state.append_result(TranscriptEntry(entry_type="result", ok=True, text=f"res {index}", summary="ok")) + + control.create_content(width=24, height=5) + control._follow_tail = False + control._scroll_anchor_line = control._scroll_targets[-1] + + result = control.mouse_handler( + MouseEvent( + position=Point(x=0, y=0), + event_type=MouseEventType.SCROLL_UP, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + ) + + self.assertIsNone(result) + self.assertEqual(control._scroll_anchor_line, control._scroll_targets[-2]) + + def test_mouse_scroll_down_moves_to_next_round_target(self): + control = TranscriptControl(TranscriptState()) + state = control._transcript_state + for index in range(1, 6): + state.append_input(f"cmd {index}") + state.append_result(TranscriptEntry(entry_type="result", ok=True, text=f"res {index}", summary="ok")) + + control.create_content(width=24, height=5) + control._follow_tail = False + control._scroll_anchor_line = control._scroll_targets[1] + + result = control.mouse_handler( + MouseEvent( + position=Point(x=0, y=0), + event_type=MouseEventType.SCROLL_DOWN, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + ) + + self.assertIsNone(result) + self.assertEqual(control._scroll_anchor_line, control._scroll_targets[2]) + + +class WheelRoutingTests(unittest.TestCase): + def test_resolve_wheel_target_returns_completion_when_completion_open(self): + self.assertEqual( + scroll_router.resolve_wheel_target(completion_open=True), + scroll_router.WHEEL_TARGET_COMPLETION, + ) + + def test_resolve_wheel_target_returns_transcript_when_completion_closed(self): + self.assertEqual( + scroll_router.resolve_wheel_target(completion_open=False), + scroll_router.WHEEL_TARGET_TRANSCRIPT, + ) + + def test_route_wheel_up_scrolls_transcript_when_completion_closed(self): + previous_session = repl.session + previous_search = repl.get_app + + class _ShellStub: + def __init__(self): + self.default_buffer = type("BufferStub", (), {"complete_state": None})() + self.transcript_up_calls = 0 + + def scroll_transcript_window_up(self): + self.transcript_up_calls += 1 + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": False})() + try: + repl._route_wheel_up(type("EventStub", (), {})()) + finally: + repl.session = previous_session + repl.get_app = previous_search + + self.assertEqual(shell.transcript_up_calls, 1) + + def test_route_wheel_up_moves_completion_when_completion_open(self): + previous_session = repl.session + previous_search = repl.get_app + + class _BufferStub: + def __init__(self): + self.complete_state = object() + self.complete_previous_calls = [] + + def complete_previous(self, count=1, disable_wrap_around=False): + self.complete_previous_calls.append((count, disable_wrap_around)) + + class _ShellStub: + def __init__(self): + self.default_buffer = _BufferStub() + self.transcript_up_calls = 0 + + def scroll_transcript_window_up(self): + self.transcript_up_calls += 1 + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": False})() + try: + repl._route_wheel_up(type("EventStub", (), {})()) + finally: + repl.session = previous_session + repl.get_app = previous_search + + self.assertEqual(shell.default_buffer.complete_previous_calls, [(3, True)]) + self.assertEqual(shell.transcript_up_calls, 0) + + def test_route_wheel_down_moves_completion_when_completion_open(self): + previous_session = repl.session + previous_search = repl.get_app + + class _BufferStub: + def __init__(self): + self.complete_state = object() + self.complete_next_calls = [] + + def complete_next(self, count=1, disable_wrap_around=False): + self.complete_next_calls.append((count, disable_wrap_around)) + + class _ShellStub: + def __init__(self): + self.default_buffer = _BufferStub() + self.transcript_down_calls = 0 + + def scroll_transcript_window_down(self): + self.transcript_down_calls += 1 + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": False})() + try: + repl._route_wheel_down(type("EventStub", (), {})()) + finally: + repl.session = previous_session + repl.get_app = previous_search + + self.assertEqual(shell.default_buffer.complete_next_calls, [(3, True)]) + self.assertEqual(shell.transcript_down_calls, 0) + + def test_route_wheel_down_scrolls_transcript_when_completion_closed(self): + previous_session = repl.session + previous_search = repl.get_app + + class _ShellStub: + def __init__(self): + self.default_buffer = type("BufferStub", (), {"complete_state": None})() + self.transcript_down_calls = 0 + + def scroll_transcript_window_down(self): + self.transcript_down_calls += 1 + + shell = _ShellStub() + repl.session = shell + repl.get_app = lambda: type("AppStub", (), {"is_searching": False})() + try: + repl._route_wheel_down(type("EventStub", (), {})()) + finally: + repl.session = previous_session + repl.get_app = previous_search + + self.assertEqual(shell.transcript_down_calls, 1) + + +class TranscriptStateTests(unittest.TestCase): + def test_transcript_state_appends_and_clears_entries(self): + state = TranscriptState() + + input_entry = state.append_input("Debug.Log(1);") + info_entry = state.append_info("Connected") + + result_entry = TranscriptEntry(entry_type="result", ok=True, text="1", summary="OK") + state.append_result(result_entry) + + self.assertEqual(len(state.entries), 3) + self.assertEqual(input_entry.entry_type, "input") + self.assertEqual(info_entry.entry_type, "info") + self.assertIs(state.entries[-1], result_entry) + + state.clear() + self.assertEqual(state.entries, []) + + def test_transcript_entry_created_at_is_available_for_timestamp_rendering(self): + entry = TranscriptEntry(entry_type="input", text="Debug.Log(1);", created_at="2026-04-04T12:34:56.000Z") + + self.assertEqual(entry.created_at, "2026-04-04T12:34:56.000Z") + self.assertEqual(session_ui.format_transcript_timestamp(entry.created_at), "12:34:56") + + +class ResultClassificationTests(unittest.TestCase): + def test_build_result_entry_classifies_compile_error(self): + result = { + "ok": False, + "stage": "compile", + "type": "compile_error", + "summary": "Compile failed: CS1002", + "data": {"text": "compile output"}, + } + + entry = output.build_result_entry(result, _extract_text_from_data) + + self.assertFalse(entry.ok) + self.assertEqual(entry.error_kind, "compile_error") + + def test_build_result_entry_classifies_timeout_error(self): + result = { + "ok": False, + "stage": "execute", + "type": "runtime_error", + "summary": "Timed out waiting for Unity service recovery", + "data": {"text": "timeout"}, + } + + entry = output.build_result_entry(result, _extract_text_from_data) + + self.assertEqual(entry.error_kind, "timeout_error") + + def test_build_result_entry_classifies_connection_error(self): + result = { + "ok": False, + "stage": "execute", + "type": "system_error", + "summary": "Error post: HTTPConnectionPool host=127.0.0.1 Failed to establish a new connection", + "data": {"text": ""}, + } + + entry = output.build_result_entry(result, _extract_text_from_data) + + self.assertEqual(entry.error_kind, "connection_error") + + def test_build_result_entry_classifies_transport_error(self): + result = { + "ok": False, + "stage": "execute", + "type": "system_error", + "summary": "Error post: malformed response", + "data": {"text": ""}, + } + + entry = output.build_result_entry(result, _extract_text_from_data) + + self.assertEqual(entry.error_kind, "transport_error") + + def test_build_result_entry_classifies_command_error(self): + result = { + "ok": False, + "stage": "command", + "type": "system_error", + "summary": "Command failed", + "data": {"text": ""}, + } + + entry = output.build_result_entry(result, _extract_text_from_data) + + self.assertEqual(entry.error_kind, "command_error") + + def test_build_result_entry_classifies_builtin_error(self): + result = { + "ok": False, + "stage": "builtin", + "type": "builtin_error", + "summary": "Builtin failed", + "data": {"text": ""}, + } + + entry = output.build_result_entry(result, _extract_text_from_data) + + self.assertEqual(entry.error_kind, "builtin_error") + + +class OutputRenderingTests(unittest.TestCase): + def test_build_result_entry_renders_non_empty_command_result_json(self): + result = { + "ok": True, + "stage": "command", + "type": "ok", + "summary": "Editor status fetched", + "data": { + "command": {"commandNamespace": "editor", "action": "status", "sessionId": "sid-1"}, + "resultJson": {"initialized": True, "port": 14500, "editorState": "ready"}, + "nextAction": "", + }, + } + + entry = output.build_result_entry(result, lambda data: data["text"] if "text" in (data or {}) else None) + + self.assertTrue(entry.ok) + self.assertEqual( + entry.text, + '{\n "initialized": true,\n "port": 14500,\n "editorState": "ready"\n}', + ) + + def test_build_result_entry_keeps_summary_for_empty_command_result_json(self): + result = { + "ok": True, + "stage": "command", + "type": "ok", + "summary": "Requested enter playmode", + "data": { + "command": {"commandNamespace": "editor", "action": "playmode.enter", "sessionId": "sid-1"}, + "resultJson": {}, + "nextAction": "", + }, + } + + entry = output.build_result_entry(result, lambda data: data["text"] if "text" in (data or {}) else None) + + self.assertTrue(entry.ok) + self.assertEqual(entry.text, "Requested enter playmode") + + def test_print_text_result_preserves_trailing_newline_verbatim(self): + result = { + "ok": True, + "stage": "builtin", + "type": "", + "summary": "Usage", + "data": {"text": "Usage: /usage\n"}, + } + + buffer = io.StringIO() + with redirect_stdout(buffer): + output.print_text_result(result, _extract_text_from_data) + + self.assertEqual(buffer.getvalue(), "Usage: /usage\n") + + +class ViewportPolicyInputHeightTests(unittest.TestCase): + def test_compute_input_height_counts_wrapped_lines_for_single_logical_line(self): + dimension = viewport_policy.compute_input_height("abcdef", available_width=2) + + self.assertEqual(dimension.preferred, 3) + + def test_compute_input_height_counts_wrapped_lines_across_multiple_logical_lines(self): + dimension = viewport_policy.compute_input_height("abcd\nefghij", available_width=3) + + self.assertEqual(dimension.preferred, 4) + + def test_compute_input_height_caps_wrapped_lines_at_max_visible_lines(self): + dimension = viewport_policy.compute_input_height("x" * 40, available_width=1) + + self.assertEqual(dimension.preferred, 8) + + +class BuiltinPayloadTests(unittest.TestCase): + def setUp(self): + self.registry = builtins.BuiltinRegistry() + + def test_process_builtin_cmd_reports_unknown_slash_command_as_builtin_error(self): + payload = builtins.process_builtin_cmd("/missing", self.registry.commands) + + self.assertTrue(payload["handled"]) + result = payload["result"] + self.assertFalse(result["ok"]) + self.assertEqual(result["stage"], "builtin") + self.assertEqual(result["type"], "builtin_error") + self.assertEqual(result["summary"], "Unknown command: /missing") + self.assertEqual(result["data"].get("text"), "") + self.assertFalse(result["data"].get("silent")) + + def test_process_builtin_cmd_returns_success_payload_when_builtin_has_no_output(self): + @self.registry.decorator("/ok", "ok") + def _ok(_message): + return None + + payload = builtins.process_builtin_cmd("/ok", self.registry.commands) + + self.assertTrue(payload["handled"]) + result = payload["result"] + self.assertTrue(result["ok"]) + self.assertEqual(result["stage"], "builtin") + self.assertEqual(result["data"].get("text"), "success\n") + self.assertFalse(result["data"].get("silent")) + + def test_process_builtin_cmd_returns_builtin_output_payload(self): + @self.registry.decorator("/usage", "usage") + def _usage(_message): + print("Usage: /usage") + return None + + payload = builtins.process_builtin_cmd("/usage", self.registry.commands) + + self.assertTrue(payload["handled"]) + self.assertEqual(payload["result"]["data"].get("text"), "Usage: /usage\n") + + def test_process_builtin_cmd_marks_silent_success_payload(self): + @self.registry.decorator("/silent", "silent") + def _silent(_message): + return "silent-success" + + payload = builtins.process_builtin_cmd("/silent", self.registry.commands) + + self.assertTrue(payload["handled"]) + self.assertTrue(payload["result"]["ok"]) + self.assertEqual(payload["result"]["data"].get("text"), "") + self.assertTrue(payload["result"]["data"].get("silent")) + + +class LoopTranscriptWiringTests(unittest.TestCase): + def test_execute_repl_snippet_appends_result_entry(self): + state = TranscriptState() + result_payload = { + "ok": True, + "stage": "execute", + "type": "", + "summary": "ok", + "data": {"text": "42\n"}, + } + + invalidations = [] + + def execute_editor_request(message, command_id, reset=False, invalidate_completion=None): + if invalidate_completion is not None: + invalidate_completion() + return result_payload + + loop.execute_repl_snippet( + message="1+41", + reset=False, + runtime_mode=False, + cmd_id="cmd-1", + execute_runtime_request=lambda *_args, **_kwargs: None, + execute_editor_request=execute_editor_request, + append_result_entry=lambda result: state.append_result(result), + build_result_entry=lambda result: output.build_result_entry(result, _extract_text_from_data), + invalidate_completion=lambda: invalidations.append("called"), + ) + + self.assertEqual(invalidations, ["called"]) + self.assertEqual(len(state.entries), 1) + self.assertEqual(state.entries[0].entry_type, "result") + self.assertTrue(state.entries[0].ok) + self.assertEqual(state.entries[0].text, "42\n") + + def test_try_process_command_expression_appends_error_entry_on_parse_error(self): + state = TranscriptState() + + handled = loop.try_process_command_expression( + message="@bad(", + cmd_id="cmd-1", + looks_like_command_expression_prefix=lambda _text: True, + parse_command_expression=lambda _text: (_ for _ in ()).throw(ValueError("expected ')'")), + request_command=lambda *_args, **_kwargs: None, + append_result_entry=lambda result: state.append_result(result), + build_result_entry=lambda result: output.build_result_entry(result, _extract_text_from_data), + ) + + self.assertTrue(handled) + self.assertEqual(len(state.entries), 1) + self.assertEqual(state.entries[0].entry_type, "result") + self.assertFalse(state.entries[0].ok) + self.assertEqual(state.entries[0].error_kind, "command_error") + self.assertIn("syntax error", state.entries[0].text) + + def test_start_repl_appends_input_before_processing_each_message(self): + state = TranscriptState() + prompted_messages = ["/completion 1", "@game.pause()", "Debug.Log(1);", None] + + class SessionStub: + style = None + + def __init__(self, queue): + self.queue = list(queue) + + def prompt(self, _message): + return self.queue.pop(0) + + session = SessionStub(prompted_messages) + processed = [] + previous_set_title = loop.set_title + previous_patch_stdout = loop.patch_stdout + + class _NoopPatchStdout: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + loop.set_title = lambda _title: None + loop.patch_stdout = lambda: _NoopPatchStdout() + try: + loop.start_repl( + ensure_prompt_session=lambda: session, + build_terminal_title=lambda: "title", + runtime_mode=False, + runtime_dll_path="", + runtime_defines_path="", + build_startup_banner=lambda: [("", "banner")], + print_help_info=lambda: None, + execute_startup_snippet=lambda: None, + process_builtin_cmd=lambda message: processed.append(("builtin", message)) or (message == "/completion 1"), + try_process_command_expression_func=lambda message: processed.append(("command", message)) or (message == "@game.pause()"), + execute_repl_snippet_func=lambda message: processed.append(("execute", message)), + build_prompt_message=lambda: [("", "> ")], + append_input_entry=lambda text: state.append_input(text), + ) + finally: + loop.patch_stdout = previous_patch_stdout + loop.set_title = previous_set_title + + self.assertEqual( + [entry.text for entry in state.entries], + ["/completion 1", "@game.pause()", "Debug.Log(1);"], + ) + self.assertEqual( + processed, + [ + ("builtin", "/completion 1"), + ("builtin", "@game.pause()"), + ("command", "@game.pause()"), + ("builtin", "Debug.Log(1);"), + ("command", "Debug.Log(1);"), + ("execute", "Debug.Log(1);"), + ], + ) + + def test_start_repl_uses_single_run_interactive_path_when_available(self): + state = TranscriptState() + previous_set_title = loop.set_title + previous_patch_stdout = loop.patch_stdout + + class _NoopPatchStdout: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + class SessionStub: + style = None + + def __init__(self, queue): + self.queue = list(queue) + self.run_interactive_calls = 0 + + def run_interactive(self, on_submit): + self.run_interactive_calls += 1 + for message in self.queue: + on_submit(message) + + def prompt(self, _message): + raise AssertionError("prompt() should not be used when run_interactive exists") + + session = SessionStub(["/completion 1", "@game.pause()", "Debug.Log(1);", None]) + processed = [] + + loop.set_title = lambda _title: None + loop.patch_stdout = lambda: _NoopPatchStdout() + try: + loop.start_repl( + ensure_prompt_session=lambda: session, + build_terminal_title=lambda: "title", + runtime_mode=False, + runtime_dll_path="", + runtime_defines_path="", + build_startup_banner=lambda: [("", "banner")], + print_help_info=lambda: None, + execute_startup_snippet=lambda: None, + process_builtin_cmd=lambda message: processed.append(("builtin", message)) or (message == "/completion 1"), + try_process_command_expression_func=lambda message: processed.append(("command", message)) or (message == "@game.pause()"), + execute_repl_snippet_func=lambda message: processed.append(("execute", message)), + build_prompt_message=lambda: [("", "> ")], + append_input_entry=lambda text: state.append_input(text), + ) + finally: + loop.patch_stdout = previous_patch_stdout + loop.set_title = previous_set_title + + self.assertEqual(session.run_interactive_calls, 1) + self.assertEqual( + [entry.text for entry in state.entries], + ["/completion 1", "@game.pause()", "Debug.Log(1);"], + ) + self.assertEqual( + processed, + [ + ("builtin", "/completion 1"), + ("builtin", "@game.pause()"), + ("command", "@game.pause()"), + ("builtin", "Debug.Log(1);"), + ("command", "Debug.Log(1);"), + ("execute", "Debug.Log(1);"), + ], + ) + + +if __name__ == "__main__": + try: + unittest.main() + finally: + if _ADDED_CORE_PATH: + sys.path.remove(CORE_PATH) + if _ADDED_SITE_PACKAGES_PATH: + sys.path.remove(SITE_PACKAGES_PATH) diff --git a/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_theme.py b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_theme.py new file mode 100644 index 0000000..ddbbd6a --- /dev/null +++ b/Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_theme.py @@ -0,0 +1,241 @@ +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +SCRIPT_ROOT = os.path.dirname(os.path.abspath(__file__)) +CONSOLE_CLIENT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_ROOT)) +SITE_PACKAGES_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "site-packages") +_ADDED_SITE_PACKAGES_PATH = False + +if CONSOLE_CLIENT_ROOT not in sys.path: + sys.path.insert(0, CONSOLE_CLIENT_ROOT) + +if SITE_PACKAGES_PATH not in sys.path: + sys.path.insert(0, SITE_PACKAGES_PATH) + _ADDED_SITE_PACKAGES_PATH = True + +CORE_PATH = os.path.join(CONSOLE_CLIENT_ROOT, "csharpconsole_core") +_ADDED_CORE_PATH = False +if CORE_PATH not in sys.path: + sys.path.insert(0, CORE_PATH) + _ADDED_CORE_PATH = True + +try: + from prompt_toolkit.completion import CompleteEvent + from prompt_toolkit.document import Document + from repl.completion import BuiltinCmdCompleter, ThemeCompleter, trigger_completion_on_change + from repl.theme import DEFAULT_THEME, ThemeManager, list_themes + import csharp_repl_core as repl +finally: + if _ADDED_CORE_PATH: + sys.path.remove(CORE_PATH) + + if _ADDED_SITE_PACKAGES_PATH: + sys.path.remove(SITE_PACKAGES_PATH) + + +def _pick_non_default_theme(): + for name in list_themes(): + if name != DEFAULT_THEME: + return name + raise AssertionError("expected at least two pygments themes") + + +class FakeThemeBuffer: + def __init__(self, text): + self.document = Document(text) + self.started_completion = False + + def start_completion(self, select_first=False): + self.started_completion = True + + +class ThemeManagerTests(unittest.TestCase): + def test_defaults_to_material(self): + with tempfile.TemporaryDirectory() as tmp: + manager = ThemeManager(None, cache_dir=tmp) + self.assertEqual(DEFAULT_THEME, "material") + self.assertEqual(manager.current_theme(), DEFAULT_THEME) + self.assertEqual(manager.active_theme(), DEFAULT_THEME) + + def test_set_theme_valid_and_invalid(self): + with tempfile.TemporaryDirectory() as tmp: + manager = ThemeManager(None, cache_dir=tmp) + other = _pick_non_default_theme() + self.assertTrue(manager.set_theme(other)) + self.assertEqual(manager.current_theme(), other) + self.assertFalse(manager.set_theme("not-a-real-theme")) + self.assertEqual(manager.current_theme(), other) + + def test_set_theme_persists_across_instances(self): + with tempfile.TemporaryDirectory() as tmp: + other = _pick_non_default_theme() + ThemeManager(None, cache_dir=tmp).set_theme(other) + reloaded = ThemeManager(None, cache_dir=tmp) + self.assertEqual(reloaded.current_theme(), other) + + def test_corrupt_persisted_theme_falls_back_to_default(self): + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, "theme.txt"), "w", encoding="utf-8") as f: + f.write("no-such-theme\n") + manager = ThemeManager(None, cache_dir=tmp) + self.assertEqual(manager.current_theme(), DEFAULT_THEME) + + def test_preview_overrides_active_until_cleared(self): + with tempfile.TemporaryDirectory() as tmp: + manager = ThemeManager(None, cache_dir=tmp) + other = _pick_non_default_theme() + self.assertTrue(manager.preview(other)) + self.assertEqual(manager.active_theme(), other) + self.assertEqual(manager.current_theme(), DEFAULT_THEME) + self.assertTrue(manager.clear_preview()) + self.assertEqual(manager.active_theme(), DEFAULT_THEME) + + def test_preview_invalid_name_reverts_to_committed(self): + with tempfile.TemporaryDirectory() as tmp: + manager = ThemeManager(None, cache_dir=tmp) + other = _pick_non_default_theme() + manager.preview(other) + self.assertTrue(manager.preview("dracul")) + self.assertEqual(manager.active_theme(), DEFAULT_THEME) + + def test_preview_same_name_reports_no_change(self): + with tempfile.TemporaryDirectory() as tmp: + manager = ThemeManager(None, cache_dir=tmp) + other = _pick_non_default_theme() + self.assertTrue(manager.preview(other)) + self.assertFalse(manager.preview(other)) + self.assertTrue(manager.clear_preview()) + self.assertFalse(manager.clear_preview()) + + def test_set_theme_clears_preview(self): + with tempfile.TemporaryDirectory() as tmp: + manager = ThemeManager(None, cache_dir=tmp) + other = _pick_non_default_theme() + manager.preview(other) + self.assertTrue(manager.set_theme(other)) + self.assertEqual(manager.active_theme(), other) + self.assertFalse(manager.clear_preview()) + + def test_active_style_differs_between_themes_and_is_cached(self): + with tempfile.TemporaryDirectory() as tmp: + manager = ThemeManager(None, cache_dir=tmp) + default_style = manager.active_style() + self.assertIs(manager.active_style(), default_style) + manager.preview(_pick_non_default_theme()) + self.assertIsNot(manager.active_style(), default_style) + + +class ThemeCompleterTests(unittest.TestCase): + def _completions(self, text): + completer = ThemeCompleter(list_themes) + document = Document(text, len(text)) + return [c.text for c in completer.get_completions(document, CompleteEvent())] + + def test_lists_all_themes_after_theme_prefix(self): + self.assertEqual(self._completions("/theme "), list(list_themes())) + + def test_filters_by_typed_prefix(self): + results = self._completions("/theme ma") + self.assertIn(DEFAULT_THEME, results) + self.assertTrue(all(name.startswith("ma") for name in results)) + + def test_ignores_non_theme_input(self): + self.assertEqual(self._completions("/dofile "), []) + self.assertEqual(self._completions("Console."), []) + + def test_ignores_extra_argument(self): + self.assertEqual(self._completions("/theme dracula "), []) + + +class BuiltinCmdCompleterArgumentStageTests(unittest.TestCase): + def test_command_completions_stop_after_space(self): + completer = BuiltinCmdCompleter(repl.builtin_cmds, repl._builtin_command_order) + document = Document("/theme ", len("/theme ")) + self.assertEqual(list(completer.get_completions(document, CompleteEvent())), []) + + +class ThemeCompletionTriggerTests(unittest.TestCase): + def test_theme_argument_stage_triggers_completion(self): + buff = FakeThemeBuffer("/theme dr") + trigger_completion_on_change(buff, lambda: True) + self.assertTrue(buff.started_completion) + + def test_other_command_argument_stage_does_not_trigger(self): + buff = FakeThemeBuffer("/dofile some") + trigger_completion_on_change(buff, lambda: True) + self.assertFalse(buff.started_completion) + + +class ThemeBuiltinTests(unittest.TestCase): + def setUp(self): + self._original_manager = repl.theme_manager + self._tmp = tempfile.TemporaryDirectory() + repl.theme_manager = ThemeManager(None, cache_dir=self._tmp.name) + + def tearDown(self): + repl.theme_manager = self._original_manager + self._tmp.cleanup() + + def _run_theme(self, argument): + output_buffer = io.StringIO() + with redirect_stdout(output_buffer): + repl.builtin_cmds["/theme"]["func"](argument) + return output_buffer.getvalue() + + def test_theme_command_is_registered(self): + self.assertIn("/theme", repl.builtin_cmds) + self.assertIn("/theme", repl._builtin_command_order) + + def test_no_argument_lists_current_and_candidates(self): + output = self._run_theme("") + self.assertIn(f"Current theme: {DEFAULT_THEME}", output) + self.assertIn("Available themes", output) + self.assertIn(DEFAULT_THEME, output) + + def test_valid_argument_switches_theme(self): + other = _pick_non_default_theme() + output = self._run_theme(other) + self.assertIn(f"Theme switched to '{other}'", output) + self.assertEqual(repl.theme_manager.current_theme(), other) + + def test_invalid_argument_reports_error(self): + output = self._run_theme("no-such-theme") + self.assertIn("Unknown theme: no-such-theme", output) + self.assertEqual(repl.theme_manager.current_theme(), DEFAULT_THEME) + + +class ThemePreviewOnTextChangeTests(unittest.TestCase): + def setUp(self): + self._original_manager = repl.theme_manager + self._tmp = tempfile.TemporaryDirectory() + repl.theme_manager = ThemeManager(None, cache_dir=self._tmp.name) + + def tearDown(self): + repl.theme_manager = self._original_manager + self._tmp.cleanup() + + def test_full_theme_name_previews_live(self): + other = _pick_non_default_theme() + repl._handle_theme_preview_on_change(FakeThemeBuffer(f"/theme {other}")) + self.assertEqual(repl.theme_manager.active_theme(), other) + self.assertEqual(repl.theme_manager.current_theme(), DEFAULT_THEME) + + def test_partial_name_reverts_preview(self): + other = _pick_non_default_theme() + repl._handle_theme_preview_on_change(FakeThemeBuffer(f"/theme {other}")) + repl._handle_theme_preview_on_change(FakeThemeBuffer("/theme dracul")) + self.assertEqual(repl.theme_manager.active_theme(), DEFAULT_THEME) + + def test_clearing_input_reverts_preview(self): + other = _pick_non_default_theme() + repl._handle_theme_preview_on_change(FakeThemeBuffer(f"/theme {other}")) + repl._handle_theme_preview_on_change(FakeThemeBuffer("")) + self.assertEqual(repl.theme_manager.active_theme(), DEFAULT_THEME) + + +if __name__ == "__main__": + unittest.main(verbosity=2)