Files
plyngent/tests/test_prompting.py
T

282 lines
8.2 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import contextlib
2026-08-11 14:44:34 +08:00
import io
from typing import TYPE_CHECKING
2026-08-11 14:44:34 +08:00
import pytest
from plyngent.prompting import (
ChoiceOption,
FormField,
NonInteractiveBackend,
NonInteractiveError,
PromptCancelledError,
2026-08-11 14:44:34 +08:00
_read_line_with_timeout_posix,
ask,
cancel_prompt_read,
choose,
configure_prompting,
confirm,
form,
get_prompt_backend,
get_prompt_output_hook,
prompt_cancelled,
2026-08-11 14:44:34 +08:00
read_line_with_timeout,
reset_prompting,
run_cancellable_prompt_async,
run_prompt_async,
set_prompt_output_hook,
temporary_backend,
)
if TYPE_CHECKING:
from collections.abc import Generator, Iterator
@pytest.fixture(autouse=True)
def reset_prompting_state() -> Iterator[None]:
"""Prompting state (backend, SIGINT guard, cancel flag) is process-global."""
reset_prompting()
yield
reset_prompting()
2026-08-11 14:44:34 +08:00
def _ready_poll(r, w, x, t):
return (r, w, x)
def _never_poll(r, w, x, t):
return ([], [], [])
def test_read_line_with_timeout_reads_line() -> None:
stream = io.StringIO("hello\n")
assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) == "hello"
def test_read_line_with_timeout_empty_enter() -> None:
stream = io.StringIO("\n")
assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) == ""
def test_read_line_with_timeout_timeout_returns_none() -> None:
stream = io.StringIO()
assert _read_line_with_timeout_posix("p> ", 0.01, stream=stream, poll=_never_poll) is None
def test_read_line_with_timeout_eof_returns_none() -> None:
stream = io.StringIO("")
assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) is None
def test_read_line_with_timeout_negative_timeout() -> None:
assert read_line_with_timeout("p> ", -1) is None
def test_read_line_with_timeout_cancelled() -> None:
"""A cancel request (SIGINT during an off-loop prompt) ends the read."""
stream = io.StringIO()
cancel_prompt_read()
try:
assert prompt_cancelled()
with pytest.raises(PromptCancelledError):
_read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_never_poll)
finally:
stream.close()
async def test_run_prompt_async_clears_cancel_flag() -> None:
"""A stale cancel request must not leak into the next prompt read."""
cancel_prompt_read()
with temporary_backend(NonInteractiveBackend()):
assert await run_prompt_async(lambda: "ok") == "ok"
assert not prompt_cancelled()
async def test_run_prompt_async_passes_cancel_hook_only_when_cancellable() -> None:
"""The CLI guard gets the read's cancel hook for poll-based reads only."""
seen: list[object] = []
@contextlib.contextmanager
def _guard(cancel: object) -> Generator[None]:
seen.append(cancel)
yield
with temporary_backend(NonInteractiveBackend()):
# temporary_backend resets the guard, so install it inside.
configure_prompting(pause_factory=_guard)
assert await run_cancellable_prompt_async(lambda: "a") == "a"
assert await run_prompt_async(lambda: "b") == "b"
assert seen == [cancel_prompt_read, None]
class ScriptedBackend:
"""Deterministic backend for unit tests."""
def __init__(
self,
lines: list[str],
*,
confirms: list[bool] | None = None,
secrets: list[str] | None = None,
) -> None:
self.lines = list(lines)
self.confirms = list(confirms or [])
self.secrets = list(secrets or [])
self.interactive = True
self.echoes: list[str] = []
def is_interactive(self) -> bool:
return self.interactive
def read_line(
self,
prompt: str,
*,
default: str | None = None,
completions: object = None,
) -> str:
del prompt, completions
if self.lines:
return self.lines.pop(0)
if default is not None:
return default
msg = "no scripted lines left"
raise NonInteractiveError(msg)
def read_secret_line(self, prompt: str) -> str:
del prompt
if self.secrets:
return self.secrets.pop(0)
msg = "no scripted secrets left"
raise NonInteractiveError(msg)
def confirm(self, prompt: str, *, default: bool = False) -> bool:
del prompt
if self.confirms:
return self.confirms.pop(0)
return default
def echo(self, message: str = "", *, err: bool = False) -> None:
del err
self.echoes.append(message)
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
del fg, err
self.echoes.append(message)
def test_ask_free_text() -> None:
backend = ScriptedBackend(["hello world"])
with temporary_backend(backend):
assert ask("Name?") == "hello world"
def test_ask_secret() -> None:
from plyngent.prompting import ask_secret
backend = ScriptedBackend([], secrets=["s3cret"])
with temporary_backend(backend):
assert ask_secret("Password?") == "s3cret"
def test_ask_default_when_empty_uses_backend_default() -> None:
backend = ScriptedBackend([])
with temporary_backend(backend):
assert ask("Name?", default="anon") == "anon"
def test_choose_by_index() -> None:
backend = ScriptedBackend(["2"])
with temporary_backend(backend):
assert (
choose(
"Pick",
[
ChoiceOption(label="a", value="A"),
ChoiceOption(label="b", value="B"),
],
allow_custom=False,
)
== "B"
)
def test_choose_by_label() -> None:
backend = ScriptedBackend(["keep"])
with temporary_backend(backend):
assert choose("Pick", ["keep", "abort"], allow_custom=False) == "keep"
def test_choose_custom_text() -> None:
backend = ScriptedBackend(["something else"])
with temporary_backend(backend):
assert choose("Pick", ["a", "b"], allow_custom=True) == "something else"
def test_choose_rejects_custom_when_disabled() -> None:
backend = ScriptedBackend(["nope", "1"])
with temporary_backend(backend):
assert choose("Pick", ["a", "b"], allow_custom=False) == "a"
def test_confirm_yes() -> None:
backend = ScriptedBackend([], confirms=[True])
with temporary_backend(backend):
assert confirm("ok?", default=False) is True
def test_form_with_confirm() -> None:
backend = ScriptedBackend(["alice", "2"], confirms=[True])
with temporary_backend(backend):
answers = form(
"Profile",
[
FormField(name="name", prompt="Name?"),
FormField(
name="role",
prompt="Role?",
options=["dev", "ops"],
allow_custom=False,
),
],
confirm_submit=True,
)
assert answers == {"name": "alice", "role": "ops"}
def test_non_interactive_ask_requires_default() -> None:
with temporary_backend(NonInteractiveBackend()), pytest.raises(NonInteractiveError):
_ = ask("Name?")
def test_non_interactive_ask_uses_default() -> None:
with temporary_backend(NonInteractiveBackend()):
assert ask("Name?", default="x") == "x"
def test_non_interactive_confirm_uses_default() -> None:
with temporary_backend(NonInteractiveBackend()):
assert confirm("ok?", default=False) is False
assert confirm("ok?", default=True) is True
def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str]) -> None:
"""A registered hook runs before echo/secho write; clearing it removes it.
The CLI registers one so an open pretty tool line ends before a prompt box.
"""
calls: list[str] = []
assert get_prompt_output_hook() is None
set_prompt_output_hook(lambda: calls.append("break"))
try:
backend = get_prompt_backend()
backend.echo("box")
backend.secho("question", fg="yellow")
finally:
set_prompt_output_hook(None)
assert calls == ["break", "break"]
assert get_prompt_output_hook() is None
assert "box" in capsys.readouterr().out