diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 309e07d..59eda22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: uv run coverage erase uv run coverage run -m pytest uv run coverage report + uv run coverage xml - name: Upload coverage data if: always() uses: actions/upload-artifact@v7 @@ -66,6 +67,11 @@ jobs: include-hidden-files: true if-no-files-found: error retention-days: 7 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v6 + with: + files: coverage.xml + fail_ci_if_error: false build: name: Build & audit package diff --git a/.gitignore b/.gitignore index f61d98c..2148e53 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ build/ .pytest_cache/ .tox/ .coverage +coverage.xml htmlcov/ .mypy_cache/ .ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..066661e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +# Mirrors the lint job in .github/workflows/ci.yml (ruff check, ruff format, +# mypy) so contributors get the same checks locally before pushing. +repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run ruff check . + language: system + types: [python] + pass_filenames: false + - id: ruff-format + name: ruff format + entry: uv run ruff format --check . + language: system + types: [python] + pass_filenames: false + - id: mypy + name: mypy type check + entry: uv run mypy gcode + language: system + types: [python] + pass_filenames: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 773c79d..c6a8875 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,21 @@ Look for issues labeled [`good first issue`](https://github.com/shauryagangrade/ - Write clean, readable code with descriptive variable names. - Ensure all tests pass before opening a PR. +## Pre-commit Hooks + +Install the hooks once so lint, formatting, and type checks run before every +commit (they mirror the CI lint job): + +```bash +uv run pre-commit install +``` + +Run them on the whole tree any time with: + +```bash +uv run pre-commit run --all-files +``` + --- ## PR Guidelines diff --git a/README.md b/README.md index 907536a..6efe1f4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # GCode ![Build](https://github.com/shauryagangrade/GCode/actions/workflows/ci.yml/badge.svg) +[![codecov](https://codecov.io/gh/shauryagangrade/GCode/branch/main/graph/badge.svg)](https://codecov.io/gh/shauryagangrade/GCode) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![PyPI version](https://img.shields.io/pypi/v/gcode.svg)](https://pypi.org/project/gcode/) [![Stars](https://img.shields.io/github/stars/shauryagangrade/GCode?style=social)](https://github.com/shauryagangrade/GCode) diff --git a/pyproject.toml b/pyproject.toml index 05e3ebc..53edb71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ "coverage>=7", "mypy>=1.13", "pip-audit>=2.7", + "pre-commit>=4.0", "pytest>=8", "ruff>=0.9", ] diff --git a/tests/test_ui.py b/tests/test_ui.py index 44ccb9c..46e3bfc 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,4 +1,6 @@ -from gcode.ui import RichUI +from unittest.mock import Mock, patch + +from gcode.ui import RichUI, _summarize_tool, _truncate from rich.console import Console @@ -13,3 +15,151 @@ def test_goodbye_renders_logo_and_session(): assert "Thanks for coding with GCode." in output assert "session" in output.lower() assert "demo-session" in output + + +# -- _truncate ------------------------------------------------------------- + + +def test_truncate_short_text_unchanged(): + assert _truncate("hello world", 50) == "hello world" + + +def test_truncate_normalizes_whitespace(): + assert _truncate("a b\n\tc", 50) == "a b c" + + +def test_truncate_long_text_appends_ellipsis(): + out = _truncate("x" * 100, 80) + assert out == "x" * 80 + "…" + + +def test_truncate_exact_limit_no_ellipsis(): + assert _truncate("x" * 80, 80) == "x" * 80 + + +def test_truncate_empty_string(): + assert _truncate("", 10) == "" + + +def test_truncate_non_string_input(): + assert _truncate(42, 10) == "42" + + +# -- _summarize_tool ------------------------------------------------------- + + +def test_summarize_tool_execute_bash_command(): + assert _summarize_tool("execute_bash", {"command": "ls -la"}) == "ls -la" + + +def test_summarize_tool_uses_path_when_present(): + assert _summarize_tool("read_file", {"path": "src/main.py"}) == "src/main.py" + + +def test_summarize_tool_non_string_path_becomes_empty(): + assert _summarize_tool("read_file", {"path": 123}) == "" + + +def test_summarize_tool_path_wins_over_other_args(): + assert _summarize_tool("grep", {"pattern": "foo", "path": "src"}) == "src" + + +def test_summarize_tool_empty_args(): + assert _summarize_tool("noop", {}) == "" + + +def test_summarize_tool_truncates_long_values(): + out = _summarize_tool("edit_file", {"path": "x" * 500}) + assert out == "x" * 120 + "…" + + +# -- _show_slash_menu ------------------------------------------------------ + + +def _mock_select(return_value): + sel = Mock() + sel.ask.return_value = return_value + return sel + + +def test_slash_menu_returns_selected_command(): + with patch( + "gcode.ui.questionary.select", + return_value=_mock_select("/help — Show available commands"), + ) as select_mock: + ui = RichUI() + assert ui._show_slash_menu() == "/help" + + choices = select_mock.call_args.kwargs["choices"] + assert choices[0].startswith("/help") + + +def test_slash_menu_cancel_returns_empty(): + with patch("gcode.ui.questionary.select", return_value=_mock_select(None)): + ui = RichUI() + assert ui._show_slash_menu() == "" + + +def test_slash_menu_keyboard_interrupt_returns_empty(): + sel = Mock() + sel.ask.side_effect = KeyboardInterrupt + with patch("gcode.ui.questionary.select", return_value=sel): + ui = RichUI() + assert ui._show_slash_menu() == "" + + +# -- streaming refresh handler --------------------------------------------- + + +class _FakeLive: + """Minimal stand-in for rich.live.Live that records updates.""" + + def __init__(self): + self.updates = [] + self.stopped = False + + def start(self): + pass + + def update(self, content): + self.updates.append(content) + + def stop(self): + self.stopped = True + + +def test_token_renders_markdown_after_threshold(monkeypatch): + from gcode import ui as ui_module + + fake = _FakeLive() + monkeypatch.setattr(ui_module, "Live", lambda *args, **kwargs: fake) + + ui = RichUI() + ui.assistant_start() + + # Below the 80-char re-render threshold: no update yet. + ui.token("x" * 40) + assert fake.updates == [] + + # Crossing the threshold triggers one Markdown update. + ui.token("y" * 50) # total 90 >= 80 + assert len(fake.updates) == 1 + + # Ending the stream renders the final text and stops the live region. + ui.assistant_end() + assert fake.stopped + assert len(fake.updates) == 2 + + +# -- tool display ---------------------------------------------------------- + + +def test_tool_start_renders_summary(): + ui = RichUI() + ui.console = Console(record=True, width=120) + + ui.tool_start("execute_bash", {"command": "ls"}) + + output = ui.console.export_text() + assert "execute_bash" in output + assert "ls" in output