diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f887b..d7edc80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented here. ## [Unreleased] ### Added +- Public `clipboard.reset_backend_cache()` helper. Replaces the previous + pattern of poking the module-private `_backend` global directly (which + the autouse fixtures in `tests/test_server.py` and + `tests/test_integration_x11.py` were already doing). Production code + has no reason to call this; it exists for tests that switch backends + or re-read `MCP_CLIPBOARD_BACKEND` mid-process. (#104) +- Direct unit tests for `parser._has_header_row` covering the + `len(rows) < 2` early-return path and the all-text-no-header path, + plus a positive case for text-headers-over-numeric-data. Previously + exercised only indirectly via `format_table` JSON output. (#104) +- Coverage for `clipboard_paste` `include_schema=True` when the data + rows are wider than the header row -- the padding loop at + `server.py:222-228` now has a regression test that asserts synthetic + `Col 3` / `Col 4` labels are emitted. (#104) +- Coverage for `MCP_CLIPBOARD_MAX_WRITE_BYTES` and + `MCP_CLIPBOARD_MAX_IMAGE_BYTES` env-var validation. Both vars are + parsed via `int(os.environ.get(...))` at module import; a non-integer + value raises `ValueError` before anything else runs. Tests exercise + each in a subprocess so the partial-import state stays out of the + in-process module cache. (#104) - Headless X11 integration tests (`tests/test_integration_x11.py`) and a matching CI `integration-x11` job that runs them against a real `xclip` process under Xvfb. Five round-trip tests exercise plain text, unicode, diff --git a/src/mcp_clipboard/clipboard.py b/src/mcp_clipboard/clipboard.py index b3436df..3de914c 100644 --- a/src/mcp_clipboard/clipboard.py +++ b/src/mcp_clipboard/clipboard.py @@ -705,6 +705,17 @@ def _get_backend() -> str: return _backend +def reset_backend_cache() -> None: + """Clear the cached backend so the next call to _get_backend() re-detects. + + Intended for tests that need to switch backends or re-read + MCP_CLIPBOARD_BACKEND mid-process. Production code should not need + this -- the cache is a process-lifetime decision. + """ + global _backend + _backend = None + + _READERS = { "wayland": _wayland_read, "x11": _x11_read, diff --git a/tests/test_integration_x11.py b/tests/test_integration_x11.py index 0a413d9..bd6ac22 100644 --- a/tests/test_integration_x11.py +++ b/tests/test_integration_x11.py @@ -21,11 +21,11 @@ import pytest -import mcp_clipboard.clipboard as cb from mcp_clipboard.clipboard import ( list_clipboard_formats, read_clipboard, read_clipboard_image, + reset_backend_cache, write_clipboard, write_clipboard_typed, ) @@ -47,9 +47,9 @@ def _force_x11_backend(monkeypatch): """Force the X11 backend for every test in this module.""" monkeypatch.setenv("MCP_CLIPBOARD_BACKEND", "x11") - cb._backend = None + reset_backend_cache() yield - cb._backend = None + reset_backend_cache() @pytest.mark.asyncio diff --git a/tests/test_parser.py b/tests/test_parser.py index 8dec0d9..728b36d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,6 +3,7 @@ import re from mcp_clipboard.parser import ( + _has_header_row, detect_content_type, extract_html_text, format_table, @@ -461,6 +462,34 @@ def test_detect_code_python_def_still_detected(): assert detect_content_type("def add(a, b):") == "code" +# --------------------------------------------------------------------------- +# _has_header_row direct coverage +# --------------------------------------------------------------------------- + + +def test_has_header_row_single_row(): + """Single row triggers the early return; can't be a header without data.""" + assert _has_header_row([["Name", "Age", "City"]]) is False + + +def test_has_header_row_empty(): + """Zero rows also takes the early-return path.""" + assert _has_header_row([]) is False + + +def test_has_header_row_uniform_text_no_header(): + """When every row (header included) is text and data is also text, no + column type differs, so no header is detected.""" + rows = [["Alice", "Bob"], ["Carol", "Dan"], ["Eve", "Frank"]] + assert _has_header_row(rows) is False + + +def test_has_header_row_text_header_over_numeric_data(): + """Classic header case: text labels over numeric data rows.""" + rows = [["Count", "Total"], ["1", "100"], ["2", "200"], ["3", "300"]] + assert _has_header_row(rows) is True + + # --------------------------------------------------------------------------- # Schema inference # --------------------------------------------------------------------------- diff --git a/tests/test_server.py b/tests/test_server.py index 48d9d0c..57a20cf 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import os from unittest.mock import AsyncMock, patch import pytest @@ -167,6 +168,70 @@ async def test_paste_schema_not_appended_for_non_table(): assert "hello world" in result +def test_max_write_bytes_non_integer_raises_at_import(): + """A non-integer MCP_CLIPBOARD_MAX_WRITE_BYTES raises ValueError at module + load (server.py runs ``int(os.environ.get(...))`` at import time). + + Run as a subprocess so the bad env var and partial-import state are + contained -- reloading mcp_clipboard.server in-process would break + every test that holds a reference to the module's exception classes. + """ + import subprocess + import sys + + env = {**os.environ, "MCP_CLIPBOARD_MAX_WRITE_BYTES": "not-a-number"} + proc = subprocess.run( + [sys.executable, "-c", "import mcp_clipboard.server"], + env=env, + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + assert "ValueError" in proc.stderr + assert "not-a-number" in proc.stderr + + +def test_max_image_bytes_non_integer_raises_at_import(): + """Same for MCP_CLIPBOARD_MAX_IMAGE_BYTES on the clipboard module.""" + import subprocess + import sys + + env = {**os.environ, "MCP_CLIPBOARD_MAX_IMAGE_BYTES": "ten-megs"} + proc = subprocess.run( + [sys.executable, "-c", "import mcp_clipboard.clipboard"], + env=env, + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + assert "ValueError" in proc.stderr + assert "ten-megs" in proc.stderr + + +@pytest.mark.asyncio +async def test_paste_with_schema_pads_short_header_row(): + """When data rows are wider than the header row, the schema table + pads with synthetic Col N labels (server.py:222-228 padding loop).""" + # Header has 2 cells, data rows have 4. Padding should add Col 3, Col 4. + html = ( + "
| Name | Age | ||
|---|---|---|---|
| Alice | 30 | Portland | Engineer |
| Bob | 25 | Seattle | Designer |