|
| 1 | +"""Regression tests for Robocop based formatting. |
| 2 | +
|
| 3 | +Reproduces https://github.com/robotcodedev/robotcode/issues/612: formatting an |
| 4 | +already formatted document repeatedly must be a no-op. The Robocop formatter |
| 5 | +mutates the model in place, so `format_robocop` has to work on an *uncached* |
| 6 | +model - otherwise the mutation corrupts the shared cached model and repeated |
| 7 | +formatting starts to oscillate (a blank line is added, removed, added, ...). |
| 8 | +
|
| 9 | +The test drives the real `RobotFormattingProtocolPart.format_robocop` and only |
| 10 | +fakes the surrounding protocol wiring, so a regression in the model handling of |
| 11 | +the formatter is caught here. |
| 12 | +""" |
| 13 | + |
| 14 | +from pathlib import Path |
| 15 | +from types import SimpleNamespace |
| 16 | +from typing import Any, List, Optional |
| 17 | +from unittest.mock import MagicMock |
| 18 | + |
| 19 | +import pytest |
| 20 | + |
| 21 | +from robotcode.core.lsp.types import FormattingOptions, TextEdit |
| 22 | +from robotcode.core.text_document import TextDocument |
| 23 | +from robotcode.core.uri import Uri |
| 24 | +from robotcode.core.utils.version import create_version_from_str |
| 25 | +from robotcode.core.workspace import WorkspaceFolder |
| 26 | +from robotcode.language_server.robotframework.parts.formatting import RobotFormattingProtocolPart |
| 27 | +from robotcode.robot.diagnostics.document_cache_helper import DocumentsCacheHelper |
| 28 | +from robotcode.robot.diagnostics.workspace_config import RobotConfig |
| 29 | + |
| 30 | +# Importing robotcode above does not pull in robocop (the formatter imports it |
| 31 | +# lazily), so the skip guard can live after the regular imports. |
| 32 | +robocop = pytest.importorskip("robocop") |
| 33 | + |
| 34 | +ROBOCOP_VERSION = create_version_from_str(robocop.__version__) |
| 35 | + |
| 36 | +pytestmark = pytest.mark.skipif( |
| 37 | + ROBOCOP_VERSION < (6, 0), |
| 38 | + reason="Robocop >= 6.0 is required for formatting", |
| 39 | +) |
| 40 | + |
| 41 | +UNFORMATTED = ( |
| 42 | + '*** Test Cases ***\nTest\n Embedded "args"\n\n\n*** Keywords ***\nEmbedded "${args}"\n No Operation\n' |
| 43 | +) |
| 44 | + |
| 45 | +# Both `section_lines` and `test_case_lines` greater than zero is what makes a |
| 46 | +# single NormalizeNewLines pass non-idempotent on this input (see #612/#361). |
| 47 | +CONFIGURE_VARIANTS = [ |
| 48 | + pytest.param(["NormalizeNewLines.enabled=True"], id="defaults"), |
| 49 | + pytest.param( |
| 50 | + [ |
| 51 | + "NormalizeNewLines.enabled=True", |
| 52 | + "NormalizeNewLines.section_lines=2", |
| 53 | + "NormalizeNewLines.test_case_lines=1", |
| 54 | + ], |
| 55 | + id="explicit-section-and-test-case-lines", |
| 56 | + ), |
| 57 | +] |
| 58 | + |
| 59 | + |
| 60 | +def _config_manager(root: Path) -> Any: |
| 61 | + if ROBOCOP_VERSION >= (8, 0): |
| 62 | + from robocop.config.manager import ConfigManager |
| 63 | + else: |
| 64 | + from robocop.config import ConfigManager |
| 65 | + |
| 66 | + return ConfigManager([], root=root, config=root / "robot.toml") |
| 67 | + |
| 68 | + |
| 69 | +def _make_formatting_part(root: Path, document: TextDocument) -> RobotFormattingProtocolPart: |
| 70 | + folder = WorkspaceFolder(name="test", uri=Uri.from_path(root)) |
| 71 | + |
| 72 | + workspace = MagicMock() |
| 73 | + workspace.get_workspace_folder.return_value = folder |
| 74 | + workspace.get_configuration.return_value = RobotConfig() |
| 75 | + |
| 76 | + documents_cache = DocumentsCacheHelper( |
| 77 | + workspace=workspace, |
| 78 | + documents_manager=MagicMock(), |
| 79 | + file_watcher_manager=MagicMock(), |
| 80 | + robot_profile=None, |
| 81 | + analysis_config=None, |
| 82 | + ) |
| 83 | + |
| 84 | + config_manager = _config_manager(root) |
| 85 | + robocop_helper = SimpleNamespace( |
| 86 | + robocop_installed=True, |
| 87 | + robocop_version=ROBOCOP_VERSION, |
| 88 | + get_config_manager=lambda _folder: config_manager, |
| 89 | + ) |
| 90 | + |
| 91 | + parent = SimpleNamespace( |
| 92 | + workspace=workspace, |
| 93 | + robocop_helper=robocop_helper, |
| 94 | + documents_cache=documents_cache, |
| 95 | + ) |
| 96 | + |
| 97 | + part = object.__new__(RobotFormattingProtocolPart) |
| 98 | + part._parent = parent # type: ignore[assignment] |
| 99 | + return part |
| 100 | + |
| 101 | + |
| 102 | +@pytest.fixture |
| 103 | +def options() -> FormattingOptions: |
| 104 | + return FormattingOptions(tab_size=4, insert_spaces=True) |
| 105 | + |
| 106 | + |
| 107 | +@pytest.mark.parametrize("configure", CONFIGURE_VARIANTS) |
| 108 | +def test_repeated_formatting_is_idempotent(tmp_path: Path, options: FormattingOptions, configure: List[str]) -> None: |
| 109 | + """After reaching a fixed point, formatting again must not change anything.""" |
| 110 | + configure_lines = "\n".join(f' "{c}",' for c in configure) |
| 111 | + (tmp_path / "robot.toml").write_text(f"[tool.robocop.format]\nconfigure = [\n{configure_lines}\n]\n") |
| 112 | + |
| 113 | + source = tmp_path / "test.robot" |
| 114 | + source.write_text(UNFORMATTED) |
| 115 | + |
| 116 | + document = TextDocument( |
| 117 | + document_uri=str(Uri.from_path(source).normalized()), |
| 118 | + language_id="robotframework", |
| 119 | + version=1, |
| 120 | + text=UNFORMATTED, |
| 121 | + ) |
| 122 | + |
| 123 | + part = _make_formatting_part(tmp_path, document) |
| 124 | + |
| 125 | + def format_once() -> Optional[List[TextEdit]]: |
| 126 | + return part.format_robocop(document, options) |
| 127 | + |
| 128 | + # Reach the formatter's fixed point first (apply the initial reformat, if any). |
| 129 | + edits = format_once() |
| 130 | + if edits: |
| 131 | + document.apply_full_change((document.version or 0) + 1, edits[0].new_text) |
| 132 | + |
| 133 | + # From the fixed point, formatting must stay a no-op - no oscillation (#612). |
| 134 | + for _ in range(5): |
| 135 | + edits = format_once() |
| 136 | + if edits: |
| 137 | + document.apply_full_change((document.version or 0) + 1, edits[0].new_text) |
| 138 | + assert edits is None, f"formatting is not idempotent, produced: {edits!r}" |
0 commit comments