|
| 1 | +"""Contract tests for the prompt_toolkit internals the reserved-row design depends on. |
| 2 | +
|
| 3 | +These lock assumptions that are *not* part of prompt_toolkit's public API. They exist so a |
| 4 | +dependency upgrade fails loudly here rather than silently breaking terminal rendering, and |
| 5 | +so the Windows-only facts are exercised by CI, which is the only place they can run. |
| 6 | +
|
| 7 | +Each test names the design requirement it protects. |
| 8 | +""" |
| 9 | + |
| 10 | +import inspect |
| 11 | +import sys |
| 12 | + |
| 13 | +import pytest |
| 14 | +from prompt_toolkit.output import DummyOutput, Output |
| 15 | +from prompt_toolkit.renderer import Renderer |
| 16 | +from prompt_toolkit.styles import default_ui_style |
| 17 | + |
| 18 | +WINDOWS_ONLY = pytest.mark.skipif(sys.platform != "win32", reason="Windows backend is importable only on Windows") |
| 19 | + |
| 20 | + |
| 21 | +def make_renderer(output: Output) -> Renderer: |
| 22 | + return Renderer(default_ui_style(), output) |
| 23 | + |
| 24 | + |
| 25 | +class TestCursorPositionArithmetic: |
| 26 | + """Protects the geometry model: physical CPR rows against a virtual total.""" |
| 27 | + |
| 28 | + def test_available_height_is_rows_minus_row_plus_one(self) -> None: |
| 29 | + """The reserved-row geometry model depends on this exact formula.""" |
| 30 | + output = DummyOutput() |
| 31 | + renderer = make_renderer(output) |
| 32 | + renderer.report_absolute_cursor_row(5) |
| 33 | + assert renderer._min_available_height == output.get_size().rows - 5 + 1 |
| 34 | + |
| 35 | + def test_cursor_in_the_reserved_band_yields_nonpositive_height(self) -> None: |
| 36 | + """The R5 hazard: a CPR answered from the reserved row reports no usable height. |
| 37 | +
|
| 38 | + With the region anchored at row 1, a virtual height of U and a physical cursor on |
| 39 | + row H > U gives a nonpositive result. On a VT backend -- where there is no native |
| 40 | + rows-below query to fall back on -- that leaves height_is_known false, so the |
| 41 | + toolbar is not drawn at all, with no error. |
| 42 | + """ |
| 43 | + physical_rows, reserved = 24, 1 |
| 44 | + usable = physical_rows - reserved |
| 45 | + |
| 46 | + class Vt100LikeOutput(DummyOutput): |
| 47 | + """A virtual size, and no native rows-below query -- as on POSIX.""" |
| 48 | + |
| 49 | + def get_size(self): # type: ignore[no-untyped-def] |
| 50 | + size = super().get_size() |
| 51 | + return type(size)(rows=usable, columns=size.columns) |
| 52 | + |
| 53 | + def get_rows_below_cursor_position(self) -> int: |
| 54 | + raise NotImplementedError |
| 55 | + |
| 56 | + renderer = make_renderer(Vt100LikeOutput()) |
| 57 | + renderer.report_absolute_cursor_row(physical_rows) # cursor parked in the band |
| 58 | + assert renderer._min_available_height <= 0 |
| 59 | + assert not renderer.height_is_known |
| 60 | + |
| 61 | + def test_a_cursor_inside_the_usable_region_keeps_height_known(self) -> None: |
| 62 | + """Positive pair for the hazard above: the same setup, cursor in the usable area.""" |
| 63 | + physical_rows, reserved = 24, 1 |
| 64 | + usable = physical_rows - reserved |
| 65 | + |
| 66 | + class Vt100LikeOutput(DummyOutput): |
| 67 | + def get_size(self): # type: ignore[no-untyped-def] |
| 68 | + size = super().get_size() |
| 69 | + return type(size)(rows=usable, columns=size.columns) |
| 70 | + |
| 71 | + def get_rows_below_cursor_position(self) -> int: |
| 72 | + raise NotImplementedError |
| 73 | + |
| 74 | + renderer = make_renderer(Vt100LikeOutput()) |
| 75 | + renderer.report_absolute_cursor_row(usable) # last usable row |
| 76 | + assert renderer._min_available_height == 1 |
| 77 | + assert renderer.height_is_known |
| 78 | + |
| 79 | + |
| 80 | +class TestEraseInterceptionPoints: |
| 81 | + """Protects the bounded-erase design: both destructive paths go through Output.""" |
| 82 | + |
| 83 | + def test_output_interface_exposes_both_erase_operations(self) -> None: |
| 84 | + assert callable(getattr(Output, "erase_down", None)) |
| 85 | + assert callable(getattr(Output, "erase_screen", None)) |
| 86 | + |
| 87 | + def test_renderer_erase_goes_through_output_erase_down(self) -> None: |
| 88 | + """`renderer.erase()` must remain interceptable at the Output boundary.""" |
| 89 | + calls: list[str] = [] |
| 90 | + |
| 91 | + class RecordingOutput(DummyOutput): |
| 92 | + def erase_down(self) -> None: |
| 93 | + calls.append("erase_down") |
| 94 | + |
| 95 | + make_renderer(RecordingOutput()).erase() |
| 96 | + assert "erase_down" in calls |
| 97 | + |
| 98 | + def test_renderer_clear_goes_through_output_erase_screen(self) -> None: |
| 99 | + """Ctrl-L must remain interceptable; an unbounded ED2 would wipe the reserved row.""" |
| 100 | + calls: list[str] = [] |
| 101 | + |
| 102 | + class RecordingOutput(DummyOutput): |
| 103 | + def erase_screen(self) -> None: |
| 104 | + calls.append("erase_screen") |
| 105 | + |
| 106 | + make_renderer(RecordingOutput()).clear() |
| 107 | + assert "erase_screen" in calls |
| 108 | + |
| 109 | + |
| 110 | +class TestDiffBaseline: |
| 111 | + """Protects the discarded-frame recovery contract (design section 7.2.1).""" |
| 112 | + |
| 113 | + def test_renderer_starts_with_no_diff_baseline(self) -> None: |
| 114 | + assert make_renderer(DummyOutput())._last_screen is None |
| 115 | + |
| 116 | + def test_reset_clears_the_diff_baseline(self) -> None: |
| 117 | + renderer = make_renderer(DummyOutput()) |
| 118 | + renderer._last_screen = object() # type: ignore[assignment] |
| 119 | + renderer.reset() |
| 120 | + assert renderer._last_screen is None |
| 121 | + |
| 122 | + |
| 123 | +@WINDOWS_ONLY |
| 124 | +class TestWindowsBackendContract: |
| 125 | + """Windows facts the design relies on. CI is the only place these can run.""" |
| 126 | + |
| 127 | + def test_windows10_output_is_a_registered_virtual_subclass(self) -> None: |
| 128 | + """isinstance succeeds, but it is not in the MRO -- capability checks must not |
| 129 | + rely on inheritance.""" |
| 130 | + from prompt_toolkit.output.windows10 import Windows10_Output |
| 131 | + |
| 132 | + assert issubclass(Windows10_Output, Output) |
| 133 | + assert Output not in Windows10_Output.__mro__ |
| 134 | + |
| 135 | + def test_geometry_is_delegated_natively(self) -> None: |
| 136 | + """Adapting get_size() alone is insufficient; available height comes from Win32.""" |
| 137 | + from prompt_toolkit.output.windows10 import Windows10_Output |
| 138 | + |
| 139 | + source = inspect.getsource(Windows10_Output.__getattr__) |
| 140 | + assert "get_size" in source |
| 141 | + assert "get_rows_below_cursor_position" in source |
| 142 | + |
| 143 | + def test_inner_vt100_output_has_a_zero_size_stub(self) -> None: |
| 144 | + """Anything wrapping Windows10_Output must never consult vt100_output.get_size().""" |
| 145 | + from prompt_toolkit.output.windows10 import Windows10_Output |
| 146 | + |
| 147 | + assert "Size(0, 0)" in inspect.getsource(Windows10_Output.__init__) |
| 148 | + |
| 149 | + def test_legacy_win32_erase_down_is_a_separate_implementation(self) -> None: |
| 150 | + """The VT sequence replacement does not reach legacy Win32Output.""" |
| 151 | + from prompt_toolkit.output.vt100 import Vt100_Output |
| 152 | + from prompt_toolkit.output.win32 import Win32Output |
| 153 | + |
| 154 | + assert Win32Output.erase_down is not Vt100_Output.erase_down |
0 commit comments