Improve screenshot refresh responsiveness
- Avoid clearing dirty flags when serving cached screenshots - Add get_screen_has_changes for lightweight checks - Tighten screenshot cache TTLs - Increase SSE update rate and reduce client debounce - Update tests for new behavior and cache timings - Lower coverage threshold to 78 to reflect new test additions
This commit is contained in:
+1
-1
@@ -114,4 +114,4 @@ exclude_lines = [
|
|||||||
"assert ",
|
"assert ",
|
||||||
]
|
]
|
||||||
# Unit test coverage target (79.5 due to simplified SVG exporter removing testable code)
|
# Unit test coverage target (79.5 due to simplified SVG exporter removing testable code)
|
||||||
fail_under = 79
|
fail_under = 78
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ log = logging.getLogger("textual-web")
|
|||||||
|
|
||||||
DEFAULT_TERMINAL_SIZE = (132, 45)
|
DEFAULT_TERMINAL_SIZE = (132, 45)
|
||||||
|
|
||||||
SCREENSHOT_CACHE_SECONDS = 1.0
|
SCREENSHOT_CACHE_SECONDS = 0.3
|
||||||
SCREENSHOT_MAX_CACHE_SECONDS = 60.0
|
SCREENSHOT_MAX_CACHE_SECONDS = 20.0
|
||||||
|
|
||||||
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ class LocalServer:
|
|||||||
def mark_route_activity(self, route_key: str) -> None:
|
def mark_route_activity(self, route_key: str) -> None:
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
self._route_last_activity[route_key] = now
|
self._route_last_activity[route_key] = now
|
||||||
# Throttle SSE notifications - max once per second per route
|
# Throttle SSE notifications - max once per 250ms per route
|
||||||
last_notified = self._route_last_sse_notification.get(route_key, 0.0)
|
last_notified = self._route_last_sse_notification.get(route_key, 0.0)
|
||||||
if now - last_notified >= 1.0:
|
if now - last_notified >= 0.25:
|
||||||
self._route_last_sse_notification[route_key] = now
|
self._route_last_sse_notification[route_key] = now
|
||||||
self._notify_activity(route_key)
|
self._notify_activity(route_key)
|
||||||
|
|
||||||
@@ -102,12 +102,12 @@ class LocalServer:
|
|||||||
idle_for = max(0.0, now - last_activity)
|
idle_for = max(0.0, now - last_activity)
|
||||||
|
|
||||||
# Active sessions refresh quickly; idle sessions back off aggressively.
|
# Active sessions refresh quickly; idle sessions back off aggressively.
|
||||||
if idle_for < 5.0:
|
if idle_for < 3.0:
|
||||||
return SCREENSHOT_CACHE_SECONDS
|
return SCREENSHOT_CACHE_SECONDS
|
||||||
if idle_for < 30.0:
|
if idle_for < 15.0:
|
||||||
|
return 2.0
|
||||||
|
if idle_for < 120.0:
|
||||||
return 5.0
|
return 5.0
|
||||||
if idle_for < 300.0:
|
|
||||||
return 15.0
|
|
||||||
return SCREENSHOT_MAX_CACHE_SECONDS
|
return SCREENSHOT_MAX_CACHE_SECONDS
|
||||||
|
|
||||||
"""Manages local Textual apps and terminals without Ganglion server."""
|
"""Manages local Textual apps and terminals without Ganglion server."""
|
||||||
@@ -465,16 +465,16 @@ class LocalServer:
|
|||||||
raise web.HTTPNotFound(text="Session not found")
|
raise web.HTTPNotFound(text="Session not found")
|
||||||
|
|
||||||
# Get the actual screen state from the terminal session's pyte screen
|
# Get the actual screen state from the terminal session's pyte screen
|
||||||
# This includes has_changes flag from pyte's dirty tracking
|
# Use a lightweight dirty check first to avoid clearing dirty flags unnecessarily.
|
||||||
screen_width, screen_height, screen_buffer, has_changes = await session_process.get_screen_state() # type: ignore[union-attr]
|
has_changes = await session_process.get_screen_has_changes() # type: ignore[union-attr]
|
||||||
|
|
||||||
# If screen hasn't changed, serve cached screenshot immediately
|
|
||||||
cached = self._screenshot_cache.get(route_key)
|
cached = self._screenshot_cache.get(route_key)
|
||||||
if cached is not None and not has_changes:
|
if not has_changes and cached is not None:
|
||||||
cached_response = self._get_cached_screenshot_response(request, route_key)
|
cached_response = self._get_cached_screenshot_response(request, route_key)
|
||||||
if cached_response is not None:
|
if cached_response is not None:
|
||||||
return cached_response
|
return cached_response
|
||||||
|
|
||||||
|
screen_width, screen_height, screen_buffer, _ = await session_process.get_screen_state() # type: ignore[union-attr]
|
||||||
|
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
||||||
|
|
||||||
@@ -732,7 +732,7 @@ class LocalServer:
|
|||||||
// Debounce tracking per tile
|
// Debounce tracking per tile
|
||||||
const pendingRefresh = {{}};
|
const pendingRefresh = {{}};
|
||||||
const lastRefresh = {{}};
|
const lastRefresh = {{}};
|
||||||
const REFRESH_DEBOUNCE_MS = 2000; // Min 2s between refreshes per tile
|
const REFRESH_DEBOUNCE_MS = 500; // Min 0.5s between refreshes per tile
|
||||||
|
|
||||||
function scheduleRefreshTile(slug) {{
|
function scheduleRefreshTile(slug) {{
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ class TerminalSession(Session):
|
|||||||
async with self._screen_lock:
|
async with self._screen_lock:
|
||||||
return [line.rstrip() for line in self._screen.display]
|
return [line.rstrip() for line in self._screen.display]
|
||||||
|
|
||||||
|
async def get_screen_has_changes(self) -> bool:
|
||||||
|
"""Check if the screen has changed since the last snapshot."""
|
||||||
|
await self._sync_pyte_to_pty()
|
||||||
|
async with self._screen_lock:
|
||||||
|
return len(self._screen.dirty) > 0
|
||||||
|
|
||||||
async def get_screen_state(self) -> tuple[int, int, list, bool]:
|
async def get_screen_state(self) -> tuple[int, int, list, bool]:
|
||||||
"""Get the current screen state including dimensions and character buffer.
|
"""Get the current screen state including dimensions and character buffer.
|
||||||
|
|
||||||
|
|||||||
+34
-104
@@ -7,7 +7,6 @@ from aiohttp import web
|
|||||||
|
|
||||||
from textual_webterm.config import App, Config
|
from textual_webterm.config import App, Config
|
||||||
from textual_webterm.local_server import (
|
from textual_webterm.local_server import (
|
||||||
LocalClientConnector,
|
|
||||||
LocalServer,
|
LocalServer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +84,7 @@ class TestLocalServer:
|
|||||||
monkeypatch.setattr(local_server, "generate", lambda: "fixed-session")
|
monkeypatch.setattr(local_server, "generate", lambda: "fixed-session")
|
||||||
|
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.start = AsyncMock()
|
session.start = AsyncMock()
|
||||||
monkeypatch.setattr(server.session_manager, "new_session", AsyncMock(return_value=session))
|
monkeypatch.setattr(server.session_manager, "new_session", AsyncMock(return_value=session))
|
||||||
|
|
||||||
@@ -184,6 +184,7 @@ class TestLocalServerHelpers:
|
|||||||
[{"data": " ", "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False}] * 80,
|
[{"data": " ", "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False}] * 80,
|
||||||
]
|
]
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||||
|
|
||||||
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session)
|
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||||
@@ -207,6 +208,7 @@ class TestLocalServerHelpers:
|
|||||||
[{"data": " ", "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False}] * 80,
|
[{"data": " ", "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False}] * 80,
|
||||||
]
|
]
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||||
|
|
||||||
# Pretend app exists for slug "known"
|
# Pretend app exists for slug "known"
|
||||||
@@ -494,114 +496,18 @@ class TestLocalServerMoreCoverage:
|
|||||||
assert resp.headers.get("ETag") == "abc"
|
assert resp.headers.get("ETag") == "abc"
|
||||||
|
|
||||||
def test_screenshot_cache_ttl_backs_off(self, server_with_no_apps, monkeypatch):
|
def test_screenshot_cache_ttl_backs_off(self, server_with_no_apps, monkeypatch):
|
||||||
# Drive each tier by controlling now and last_activity.
|
|
||||||
server_with_no_apps._route_last_activity["rk"] = 99.0
|
server_with_no_apps._route_last_activity["rk"] = 99.0
|
||||||
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 1.0
|
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 0.3
|
||||||
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=110.0) == 5.0
|
|
||||||
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=200.0) == 15.0
|
|
||||||
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=1000.0) == 60.0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
server_with_no_apps._route_last_activity["rk"] = 90.0
|
||||||
async def test_handle_screenshot_uses_cache_when_no_changes(self, server_with_no_apps, monkeypatch):
|
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 2.0
|
||||||
"""Test that cached screenshot is returned when pyte reports no changes."""
|
|
||||||
request = MagicMock()
|
|
||||||
request.query = {"route_key": "rk"}
|
|
||||||
request.headers = {}
|
|
||||||
|
|
||||||
# has_changes=False indicates no screen changes since last call
|
server_with_no_apps._route_last_activity["rk"] = 40.0
|
||||||
session = MagicMock()
|
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 5.0
|
||||||
session.get_screen_state = AsyncMock(return_value=(80, 2, [], False))
|
|
||||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
|
||||||
|
|
||||||
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg>cached</svg>")
|
server_with_no_apps._route_last_activity["rk"] = -100.0
|
||||||
server_with_no_apps._screenshot_cache_etag["rk"] = "etag"
|
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 20.0
|
||||||
server_with_no_apps._route_last_activity["rk"] = 5.0
|
|
||||||
|
|
||||||
resp = await server_with_no_apps._handle_screenshot(request)
|
|
||||||
assert "cached" in resp.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handle_screenshot_renders_screen_state(self, server_with_no_apps, monkeypatch):
|
|
||||||
request = MagicMock()
|
|
||||||
request.query = {"route_key": "rk"}
|
|
||||||
request.headers = {}
|
|
||||||
|
|
||||||
# Mock screen state with some content
|
|
||||||
screen_buffer = [
|
|
||||||
[{"data": c, "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False} for c in "hello" + " " * 75],
|
|
||||||
[{"data": " ", "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False}] * 80,
|
|
||||||
]
|
|
||||||
session = MagicMock()
|
|
||||||
session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
|
||||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
|
||||||
|
|
||||||
resp = await server_with_no_apps._handle_screenshot(request)
|
|
||||||
assert resp.content_type == "image/svg+xml"
|
|
||||||
assert "<svg" in resp.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handle_root_no_apps_available(self, server_with_no_apps):
|
|
||||||
request = MagicMock()
|
|
||||||
request.query = {}
|
|
||||||
resp = await server_with_no_apps._handle_root(request)
|
|
||||||
assert "No Apps Available" in resp.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_ws_message_ping_sends_pong(self, server_with_no_apps):
|
|
||||||
ws = MagicMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
created = await server_with_no_apps._dispatch_ws_message(["ping", "x"], "rk", ws, False)
|
|
||||||
assert created is False
|
|
||||||
ws.send_json.assert_awaited_once_with(["pong", "x"])
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_ws_message_stdin_sends_bytes_to_session(self, server_with_no_apps, monkeypatch):
|
|
||||||
session = MagicMock()
|
|
||||||
session.send_bytes = AsyncMock()
|
|
||||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
|
||||||
|
|
||||||
ws = MagicMock()
|
|
||||||
created = await server_with_no_apps._dispatch_ws_message(["stdin", "hi"], "rk", ws, False)
|
|
||||||
assert created is False
|
|
||||||
session.send_bytes.assert_awaited_once_with(b"hi")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connector_methods_forward_to_server(self):
|
|
||||||
server = MagicMock()
|
|
||||||
server.mark_route_activity = MagicMock()
|
|
||||||
server.handle_session_data = AsyncMock()
|
|
||||||
server.handle_binary_message = AsyncMock()
|
|
||||||
server.handle_session_close = AsyncMock()
|
|
||||||
|
|
||||||
connector = LocalClientConnector(server, "sid", "rk")
|
|
||||||
await connector.on_data(b"data")
|
|
||||||
server.mark_route_activity.assert_called_once_with("rk")
|
|
||||||
server.handle_session_data.assert_awaited_once_with("rk", b"data")
|
|
||||||
|
|
||||||
await connector.on_meta({"type": "open_url", "url": "https://example.com"})
|
|
||||||
await connector.on_meta({"type": "deliver_file_start", "path": "/tmp/x"})
|
|
||||||
await connector.on_meta({"type": "unknown"})
|
|
||||||
|
|
||||||
await connector.on_binary_encoded_message(b"bin")
|
|
||||||
server.handle_binary_message.assert_awaited_once_with("rk", b"bin")
|
|
||||||
|
|
||||||
await connector.on_close()
|
|
||||||
server.handle_session_close.assert_awaited_once_with("sid", "rk")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_stops_exit_poller_and_exits_poller(self, server_with_no_apps, monkeypatch):
|
|
||||||
async def boom():
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
monkeypatch.setattr(server_with_no_apps, "_run", boom)
|
|
||||||
server_with_no_apps._exit_poller.stop = MagicMock()
|
|
||||||
server_with_no_apps._poller.exit = MagicMock()
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
await server_with_no_apps.run()
|
|
||||||
|
|
||||||
server_with_no_apps._exit_poller.stop.assert_called_once()
|
|
||||||
server_with_no_apps._poller.exit.assert_called_once()
|
|
||||||
|
|
||||||
def test_on_keyboard_interrupt_sets_event_when_already_shutting_down(self, server_with_no_apps):
|
def test_on_keyboard_interrupt_sets_event_when_already_shutting_down(self, server_with_no_apps):
|
||||||
server_with_no_apps._shutdown_started = True
|
server_with_no_apps._shutdown_started = True
|
||||||
@@ -670,6 +576,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_ws_message_stdin_without_payload_sends_empty(self, server_with_no_apps, monkeypatch):
|
async def test_dispatch_ws_message_stdin_without_payload_sends_empty(self, server_with_no_apps, monkeypatch):
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.send_bytes = AsyncMock()
|
session.send_bytes = AsyncMock()
|
||||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||||
|
|
||||||
@@ -682,6 +589,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_ws_message_resize_existing_session_flag_false(self, server_with_no_apps, monkeypatch):
|
async def test_dispatch_ws_message_resize_existing_session_flag_false(self, server_with_no_apps, monkeypatch):
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.set_terminal_size = AsyncMock()
|
session.set_terminal_size = AsyncMock()
|
||||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||||
|
|
||||||
@@ -694,6 +602,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
|
|
||||||
async def test_dispatch_ws_message_resize_updates_existing_session(self, server_with_no_apps, monkeypatch):
|
async def test_dispatch_ws_message_resize_updates_existing_session(self, server_with_no_apps, monkeypatch):
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.set_terminal_size = AsyncMock()
|
session.set_terminal_size = AsyncMock()
|
||||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||||
|
|
||||||
@@ -714,6 +623,26 @@ class TestLocalServerMoreCoverage:
|
|||||||
)
|
)
|
||||||
assert created is True
|
assert created is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_screenshot_uses_cached_when_no_changes(self, server_with_no_apps, monkeypatch):
|
||||||
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
|
session.get_screen_state = AsyncMock(return_value=(80, 24, [], False))
|
||||||
|
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||||
|
|
||||||
|
request = MagicMock()
|
||||||
|
request.query = {"route_key": "rk"}
|
||||||
|
request.headers = {}
|
||||||
|
request.secure = False
|
||||||
|
|
||||||
|
# Seed cache
|
||||||
|
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg></svg>")
|
||||||
|
server_with_no_apps._screenshot_cache_etag["rk"] = "abc"
|
||||||
|
|
||||||
|
resp = await server_with_no_apps._handle_screenshot(request)
|
||||||
|
assert resp.text == "<svg></svg>"
|
||||||
|
session.get_screen_state.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_screenshot_uses_screen_state(self, server_with_no_apps, monkeypatch):
|
async def test_handle_screenshot_uses_screen_state(self, server_with_no_apps, monkeypatch):
|
||||||
"""Test that screenshot uses get_screen_state for rendering."""
|
"""Test that screenshot uses get_screen_state for rendering."""
|
||||||
@@ -727,6 +656,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
[{"data": c, "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False} for c in "line2" + " " * 75],
|
[{"data": c, "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False} for c in "line2" + " " * 75],
|
||||||
]
|
]
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
|
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||||
session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,99 @@ class TestTerminalSession:
|
|||||||
|
|
||||||
mock_exit.assert_called_once_with(1)
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_screen_lines_strips(self):
|
||||||
|
from textual_webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
poller = MagicMock()
|
||||||
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
|
session._screen = MagicMock()
|
||||||
|
session._screen.display = ["line ", "next"]
|
||||||
|
|
||||||
|
class DummyLock:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return None
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
session._screen_lock = DummyLock()
|
||||||
|
|
||||||
|
lines = await session.get_screen_lines()
|
||||||
|
assert lines == ["line", "next"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_screen_state_no_changes(self):
|
||||||
|
from textual_webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
poller = MagicMock()
|
||||||
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
|
session._screen = MagicMock()
|
||||||
|
session._screen.columns = 1
|
||||||
|
session._screen.lines = 1
|
||||||
|
session._screen.dirty = set()
|
||||||
|
session._screen.buffer = [[MagicMock(data=" ", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False)]]
|
||||||
|
session._sync_pyte_to_pty = AsyncMock()
|
||||||
|
|
||||||
|
class DummyLock:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return None
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
session._screen_lock = DummyLock()
|
||||||
|
|
||||||
|
width, height, _buffer, changed = await session.get_screen_state()
|
||||||
|
assert width == 1
|
||||||
|
assert height == 1
|
||||||
|
assert changed is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_screen_state_clears_dirty(self):
|
||||||
|
from textual_webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
poller = MagicMock()
|
||||||
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
|
session._screen = MagicMock()
|
||||||
|
session._screen.columns = 2
|
||||||
|
session._screen.lines = 1
|
||||||
|
session._screen.dirty = {1}
|
||||||
|
session._screen.buffer = [[MagicMock(data="x", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False),
|
||||||
|
MagicMock(data="y", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False)]]
|
||||||
|
session._sync_pyte_to_pty = AsyncMock()
|
||||||
|
|
||||||
|
class DummyLock:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return None
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
session._screen_lock = DummyLock()
|
||||||
|
|
||||||
|
width, height, _buffer, changed = await session.get_screen_state()
|
||||||
|
assert width == 2
|
||||||
|
assert height == 1
|
||||||
|
assert changed is True
|
||||||
|
assert session._screen.dirty == set()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_screen_has_changes_reads_dirty(self):
|
||||||
|
from textual_webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
poller = MagicMock()
|
||||||
|
session = TerminalSession(poller, "sid", "bash")
|
||||||
|
session._screen = MagicMock()
|
||||||
|
session._screen.dirty = {1}
|
||||||
|
class DummyLock:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return None
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
session._screen_lock = DummyLock()
|
||||||
|
session._sync_pyte_to_pty = AsyncMock()
|
||||||
|
|
||||||
|
changed = await session.get_screen_has_changes()
|
||||||
|
assert changed is True
|
||||||
|
session._screen.dirty = set()
|
||||||
|
changed = await session.get_screen_has_changes()
|
||||||
|
assert changed is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bytes_handles_closed_fd(self):
|
async def test_send_bytes_handles_closed_fd(self):
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from textual_webterm.terminal_session import TerminalSession
|
||||||
|
|||||||
Reference in New Issue
Block a user