From 8c696af06dc22330a36d656836796273edf2da1d Mon Sep 17 00:00:00 2001 From: niqibiao Date: Sat, 15 Aug 2026 04:23:00 -0400 Subject: [PATCH 1/2] ci: check the action tables and meta files without Unity The README action tables can fall behind the [CommandAction] declarations without anyone noticing -- #12 added ten rows that had been missing for some time. A tracked file arriving without its .meta has the same shape: nothing complains until someone imports the package. Both are decidable from the tree alone, so they run on every push. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/check_package.py | 193 +++++++++++++++++++++++++++++++ .github/workflows/checks.yml | 23 ++++ 2 files changed, 216 insertions(+) create mode 100644 .github/scripts/check_package.py create mode 100644 .github/workflows/checks.yml 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..494bd3a --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,23 @@ +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 From adb5e58e655f8e3ce616ba6565fd3e29e4bb2dd7 Mon Sep 17 00:00:00 2001 From: niqibiao Date: Sat, 15 Aug 2026 04:24:38 -0400 Subject: [PATCH 2/2] ci: track the Python suites and run them The client under Editor/ExternalTool~/console-client has 243 tests, and its own .gitignore excluded the whole tests/ directory. The two commands the contributor guide names could therefore only be run by someone who already had the files, and nothing verified the shared core that the external CLI loads through this package. Unity never imports a ~ suffixed directory, so the suites add 229 KB to the package and nothing to a consuming project. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/checks.yml | 9 + .../ExternalTool~/console-client/.gitignore | 1 - .../tests/csharpconsole_core/_bootstrap.py | 8 + .../csharpconsole_core/test_client_base.py | 67 + .../test_command_protocol.py | 123 + .../csharpconsole_core/test_config_base.py | 27 + .../tests/csharpconsole_core/test_models.py | 27 + .../tests/csharpconsole_core/test_output.py | 32 + .../test_response_parser.py | 68 + .../test_runtime_artifacts_base.py | 29 + .../csharpconsole_core/test_transport_http.py | 73 + .../tests_repl/test_repl_direct_launch.py | 309 +++ .../tests/tests_repl/test_repl_session_ui.py | 76 + .../tests_repl/test_repl_tab_completion.py | 2119 +++++++++++++++++ .../tests_repl/test_repl_terminal_layout.py | 1648 +++++++++++++ .../tests/tests_repl/test_repl_theme.py | 241 ++ 16 files changed, 4856 insertions(+), 1 deletion(-) create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/_bootstrap.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_client_base.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_command_protocol.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_config_base.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_models.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_output.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_response_parser.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_runtime_artifacts_base.py create mode 100644 Editor/ExternalTool~/console-client/tests/csharpconsole_core/test_transport_http.py create mode 100644 Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_direct_launch.py create mode 100644 Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_session_ui.py create mode 100644 Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_tab_completion.py create mode 100644 Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_terminal_layout.py create mode 100644 Editor/ExternalTool~/console-client/tests/tests_repl/test_repl_theme.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 494bd3a..abfce5d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -21,3 +21,12 @@ jobs: - 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)