Skip to content

Commit 56aa0af

Browse files
committed
Add contract tests for the prompt_toolkit internals we depend on
The reserved-row toolbar work relies on details of prompt_toolkit that are not public API: the cursor-position-report arithmetic, both destructive erase calls going through the Output interface, and the renderer's diff baseline. A silent change to any of these would break terminal rendering in ways that are hard to attribute, so lock them behaviorally here instead. The Windows cases can only run on Windows, where CI is the only place they get exercised: Windows10_Output is a registered virtual subclass rather than a real one, so capability checks must not rely on inheritance; geometry is delegated to the native backend, so adapting get_size() alone is insufficient; its inner VT output carries a zero-size stub; and legacy Win32Output.erase_down is a separate implementation that a VT sequence replacement never reaches. Verified by mutating prompt_toolkit locally: breaking the CPR formula, removing either erase call, or leaving the diff baseline set each fails these tests.
1 parent 4740af4 commit 56aa0af

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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

Comments
 (0)