feat(core): clone commands and actions when binding runtimes
Add clone support across Action types and Command so commands can be safely registered or runner-bound without mutating the original instances. - clone BaseAction implementations across simple, composite, IO, prompt, file, HTTP, process, and signal actions - bind cloned commands in Falyx.add_command_from_command() and CommandRunner - preserve local never_prompt settings when cloning actions - rename shared runtime state from options to options_manager for consistency - seed root and execution option namespaces consistently - apply scoped root and namespace option overrides during routing and dispatch - improve namespace completion by delegating option suggestions to FalyxParser - enrich missing-value errors and error hints
This commit is contained in:
489
tests/test_selection.py
Normal file
489
tests/test_selection.py
Normal file
@@ -0,0 +1,489 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from rich import box
|
||||
from rich.table import Table
|
||||
|
||||
import falyx.selection as selection_module
|
||||
from falyx.selection import (
|
||||
SelectionOption,
|
||||
SelectionOptionMap,
|
||||
get_selection_from_dict_menu,
|
||||
prompt_for_index,
|
||||
prompt_for_selection,
|
||||
render_selection_dict_table,
|
||||
render_selection_grid,
|
||||
render_selection_indexed_table,
|
||||
render_table_base,
|
||||
select_key_from_dict,
|
||||
select_value_from_dict,
|
||||
select_value_from_list,
|
||||
)
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
class CaptureConsole:
|
||||
def __init__(self) -> None:
|
||||
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
|
||||
def print(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.printed.append((args, kwargs))
|
||||
|
||||
|
||||
class FakePromptSession:
|
||||
def __init__(self, *responses: str) -> None:
|
||||
self.responses = list(responses)
|
||||
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
self.message: Any = "original-message"
|
||||
self.validator: Any = "original-validator"
|
||||
self.placeholder: Any = "original-placeholder"
|
||||
|
||||
async def prompt_async(self, *args: Any, **kwargs: Any) -> str:
|
||||
self.calls.append((args, kwargs))
|
||||
self.message = kwargs.get("message")
|
||||
self.validator = kwargs.get("validator")
|
||||
self.placeholder = kwargs.get("placeholder")
|
||||
if not self.responses:
|
||||
raise AssertionError("No fake prompt response configured")
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_options() -> dict[str, SelectionOption]:
|
||||
return {
|
||||
"dev": SelectionOption("Development", "dev-value", style="green"),
|
||||
"prod": SelectionOption("Production", "prod-value", style="red"),
|
||||
"stage": SelectionOption("Staging", "stage-value", style="yellow"),
|
||||
}
|
||||
|
||||
|
||||
def test_selection_option_rejects_non_string_description() -> None:
|
||||
with pytest.raises(TypeError, match="description must be a string"):
|
||||
SelectionOption(123, "value") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_selection_option_render_escapes_key_and_applies_style() -> None:
|
||||
option = SelectionOption("Deploy [prod]", "prod", style="red")
|
||||
|
||||
rendered = option.render("a[b]")
|
||||
|
||||
assert "a" in rendered
|
||||
assert "Deploy [prod]" in rendered
|
||||
assert f"[{OneColors.WHITE}]" in rendered
|
||||
assert "[red]" in rendered
|
||||
|
||||
|
||||
def test_selection_option_copy_returns_independent_equivalent_option() -> None:
|
||||
option = SelectionOption("Development", {"env": "dev"}, style="green")
|
||||
|
||||
copied = option.copy()
|
||||
|
||||
assert copied == option
|
||||
assert copied is not option
|
||||
|
||||
|
||||
def test_selection_option_map_initializes_from_options_case_insensitively(
|
||||
sample_options: dict[str, SelectionOption],
|
||||
) -> None:
|
||||
mapping = SelectionOptionMap({"dev": sample_options["dev"]})
|
||||
|
||||
assert mapping["DEV"] is sample_options["dev"]
|
||||
assert mapping["dev"] is sample_options["dev"]
|
||||
assert list(mapping.items()) == [("DEV", sample_options["dev"])]
|
||||
|
||||
|
||||
def test_selection_option_map_rejects_non_selection_option_values() -> None:
|
||||
mapping = SelectionOptionMap()
|
||||
|
||||
with pytest.raises(TypeError, match="must be a SelectionOption"):
|
||||
mapping["bad"] = "not an option" # type: ignore[assignment]
|
||||
|
||||
with pytest.raises(TypeError, match="must be a SelectionOption"):
|
||||
mapping.update({"bad": "not an option"})
|
||||
|
||||
with pytest.raises(TypeError, match="must be a SelectionOption"):
|
||||
mapping.update(bad="not an option")
|
||||
|
||||
|
||||
def test_selection_option_map_update_accepts_kwargs_and_copy_is_deep_for_options(
|
||||
sample_options: dict[str, SelectionOption],
|
||||
) -> None:
|
||||
mapping = SelectionOptionMap()
|
||||
mapping.update(dev=sample_options["dev"])
|
||||
mapping.update({"prod": sample_options["prod"]})
|
||||
|
||||
copied = mapping.copy()
|
||||
|
||||
assert copied.allow_reserved is mapping.allow_reserved
|
||||
assert copied["DEV"] == mapping["dev"]
|
||||
assert copied["DEV"] is not mapping["dev"]
|
||||
assert copied["PROD"].description == "Production"
|
||||
|
||||
|
||||
def test_selection_option_map_reserved_key_protection_and_bypass(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
|
||||
mapping = SelectionOptionMap()
|
||||
reserved_option = SelectionOption("Quit", None)
|
||||
|
||||
with pytest.raises(ValueError, match="reserved"):
|
||||
mapping["quit"] = reserved_option
|
||||
|
||||
mapping._add_reserved("quit", reserved_option)
|
||||
assert mapping["QUIT"] is reserved_option
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot delete reserved option"):
|
||||
del mapping["quit"]
|
||||
|
||||
assert list(mapping.items(include_reserved=False)) == []
|
||||
|
||||
|
||||
def test_selection_option_map_allows_reserved_key_when_configured(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
|
||||
mapping = SelectionOptionMap(allow_reserved=True)
|
||||
option = SelectionOption("Quit", None)
|
||||
|
||||
mapping["quit"] = option
|
||||
assert mapping["QUIT"] is option
|
||||
|
||||
del mapping["quit"]
|
||||
assert "QUIT" not in mapping
|
||||
|
||||
|
||||
def test_render_table_base_uses_explicit_column_names_and_styles() -> None:
|
||||
table = render_table_base(
|
||||
"Environments",
|
||||
caption="Choose carefully",
|
||||
column_names=["Name", "Description"],
|
||||
box_style=box.ROUNDED,
|
||||
show_lines=True,
|
||||
show_header=True,
|
||||
show_footer=True,
|
||||
style="blue",
|
||||
header_style="bold",
|
||||
footer_style="dim",
|
||||
title_style="green",
|
||||
caption_style="yellow",
|
||||
highlight=False,
|
||||
)
|
||||
|
||||
assert isinstance(table, Table)
|
||||
assert table.title == "Environments"
|
||||
assert table.caption == "Choose carefully"
|
||||
assert len(table.columns) == 2
|
||||
assert table.columns[0].header == "Name"
|
||||
assert table.columns[1].header == "Description"
|
||||
assert table.show_header is True
|
||||
assert table.show_footer is True
|
||||
assert table.highlight is False
|
||||
|
||||
|
||||
def test_render_table_base_creates_blank_columns_when_no_names_are_given() -> None:
|
||||
table = render_table_base("Choices", columns=3)
|
||||
|
||||
assert len(table.columns) == 3
|
||||
|
||||
|
||||
def test_render_selection_grid_chunks_rows() -> None:
|
||||
table = render_selection_grid("Choices", ["alpha", "beta", "gamma"], columns=2)
|
||||
|
||||
assert table.title == "Choices"
|
||||
assert len(table.columns) == 2
|
||||
assert len(table.rows) == 2
|
||||
|
||||
|
||||
def test_render_selection_indexed_table_uses_default_and_custom_formatters() -> None:
|
||||
default_table = render_selection_indexed_table(
|
||||
"Indexed", ["alpha", "beta", "gamma"], columns=2
|
||||
)
|
||||
formatted_table = render_selection_indexed_table(
|
||||
"Formatted",
|
||||
["alpha", "beta"],
|
||||
columns=2,
|
||||
formatter=lambda index, value: f"{index}:{value.upper()}",
|
||||
)
|
||||
|
||||
assert len(default_table.rows) == 2
|
||||
assert len(formatted_table.rows) == 1
|
||||
|
||||
|
||||
def test_render_selection_dict_table_renders_option_rows(
|
||||
sample_options: dict[str, SelectionOption],
|
||||
) -> None:
|
||||
table = render_selection_dict_table(
|
||||
"Environments",
|
||||
sample_options,
|
||||
columns=2,
|
||||
caption="Pick one",
|
||||
highlight=True,
|
||||
)
|
||||
|
||||
assert table.title == "Environments"
|
||||
assert table.caption == "Pick one"
|
||||
assert len(table.columns) == 2
|
||||
assert len(table.rows) == 2
|
||||
assert table.highlight is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_for_index_returns_single_index_and_restores_prompt_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_console = CaptureConsole()
|
||||
monkeypatch.setattr(selection_module, "console", fake_console)
|
||||
session = FakePromptSession(" 2 ")
|
||||
table = render_table_base("Choices")
|
||||
|
||||
result = await prompt_for_index(
|
||||
5,
|
||||
table,
|
||||
default_selection="1",
|
||||
prompt_session=session, # type: ignore[arg-type]
|
||||
prompt_message="[bold]Pick >[/] ",
|
||||
show_table=True,
|
||||
number_selections=1,
|
||||
cancel_key="9",
|
||||
)
|
||||
|
||||
assert result == 2
|
||||
assert len(fake_console.printed) == 1
|
||||
_, kwargs = session.calls[0]
|
||||
assert kwargs["default"] == "1"
|
||||
assert kwargs["placeholder"] == "Enter selection"
|
||||
assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
|
||||
assert session.message == "original-message"
|
||||
assert session.validator == "original-validator"
|
||||
assert session.placeholder == "original-placeholder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_for_index_returns_cancel_key_as_int() -> None:
|
||||
session = FakePromptSession(" 7 ")
|
||||
table = render_table_base("Choices")
|
||||
|
||||
result = await prompt_for_index(
|
||||
7,
|
||||
table,
|
||||
prompt_session=session, # type: ignore[arg-type]
|
||||
show_table=False,
|
||||
cancel_key="7",
|
||||
)
|
||||
|
||||
assert result == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_for_index_returns_multiple_indexes_with_custom_separator() -> None:
|
||||
session = FakePromptSession("0 ; 2 ; 4")
|
||||
table = render_table_base("Choices")
|
||||
|
||||
result = await prompt_for_index(
|
||||
5,
|
||||
table,
|
||||
prompt_session=session, # type: ignore[arg-type]
|
||||
show_table=False,
|
||||
number_selections=3,
|
||||
separator=";",
|
||||
allow_duplicates=True,
|
||||
)
|
||||
|
||||
assert result == [0, 2, 4]
|
||||
_, kwargs = session.calls[0]
|
||||
assert kwargs["placeholder"] == "Enter 3 selections separated by ';'"
|
||||
assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_for_selection_returns_single_key_and_prints_table(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_console = CaptureConsole()
|
||||
monkeypatch.setattr(selection_module, "console", fake_console)
|
||||
session = FakePromptSession(" DEV ")
|
||||
table = render_table_base("Choices")
|
||||
|
||||
result = await prompt_for_selection(
|
||||
["DEV", "PROD"],
|
||||
table,
|
||||
default_selection="PROD",
|
||||
prompt_session=session, # type: ignore[arg-type]
|
||||
prompt_message="Select > ",
|
||||
show_table=True,
|
||||
number_selections=1,
|
||||
cancel_key="Q",
|
||||
)
|
||||
|
||||
assert result == "DEV"
|
||||
assert len(fake_console.printed) == 1
|
||||
_, kwargs = session.calls[0]
|
||||
assert kwargs["default"] == "PROD"
|
||||
assert kwargs["placeholder"] == "Enter selection"
|
||||
assert kwargs["validator"].__class__.__name__ == "MultiKeyValidator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_for_selection_returns_cancel_key() -> None:
|
||||
session = FakePromptSession(" q ")
|
||||
table = render_table_base("Choices")
|
||||
|
||||
result = await prompt_for_selection(
|
||||
["dev", "prod", "q"],
|
||||
table,
|
||||
prompt_session=session, # type: ignore[arg-type]
|
||||
show_table=False,
|
||||
cancel_key="q",
|
||||
)
|
||||
|
||||
assert result == "q"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_for_selection_returns_multiple_keys_with_custom_separator() -> None:
|
||||
session = FakePromptSession("dev | prod | stage")
|
||||
table = render_table_base("Choices")
|
||||
|
||||
result = await prompt_for_selection(
|
||||
["dev", "prod", "stage"],
|
||||
table,
|
||||
prompt_session=session, # type: ignore[arg-type]
|
||||
show_table=False,
|
||||
number_selections="*",
|
||||
separator="|",
|
||||
allow_duplicates=True,
|
||||
)
|
||||
|
||||
assert result == ["dev", "prod", "stage"]
|
||||
_, kwargs = session.calls[0]
|
||||
assert kwargs["placeholder"] == "Enter selections separated by '|'"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_value_from_list_returns_single_and_multiple_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
prompt_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_prompt_for_index(max_index: int, table: Table, **kwargs: Any) -> Any:
|
||||
prompt_calls.append({"max_index": max_index, "table": table, **kwargs})
|
||||
return [0, 2] if kwargs["number_selections"] != 1 else 1
|
||||
|
||||
monkeypatch.setattr(selection_module, "prompt_for_index", fake_prompt_for_index)
|
||||
|
||||
single = await select_value_from_list(
|
||||
"Languages",
|
||||
["python", "rust", "go"],
|
||||
default_selection="1",
|
||||
number_selections=1,
|
||||
)
|
||||
multiple = await select_value_from_list(
|
||||
"Languages",
|
||||
["python", "rust", "go"],
|
||||
default_selection="0,2",
|
||||
number_selections=2,
|
||||
)
|
||||
|
||||
assert single == "rust"
|
||||
assert multiple == ["python", "go"]
|
||||
assert prompt_calls[0]["max_index"] == 2
|
||||
assert prompt_calls[0]["default_selection"] == "1"
|
||||
assert prompt_calls[1]["number_selections"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_key_from_dict_delegates_to_prompt_for_selection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sample_options: dict[str, SelectionOption],
|
||||
) -> None:
|
||||
fake_console = CaptureConsole()
|
||||
monkeypatch.setattr(selection_module, "console", fake_console)
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> str:
|
||||
calls.append({"keys": list(keys), "table": table, **kwargs})
|
||||
return "prod"
|
||||
|
||||
monkeypatch.setattr(
|
||||
selection_module, "prompt_for_selection", fake_prompt_for_selection
|
||||
)
|
||||
table = render_table_base("Environments")
|
||||
|
||||
result = await select_key_from_dict(
|
||||
sample_options,
|
||||
table,
|
||||
default_selection="dev",
|
||||
number_selections=1,
|
||||
cancel_key="q",
|
||||
)
|
||||
|
||||
assert result == "prod"
|
||||
assert len(fake_console.printed) == 1
|
||||
assert calls[0]["keys"] == ["dev", "prod", "stage"]
|
||||
assert calls[0]["default_selection"] == "dev"
|
||||
assert calls[0]["cancel_key"] == "q"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_value_from_dict_returns_single_and_multiple_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sample_options: dict[str, SelectionOption],
|
||||
) -> None:
|
||||
fake_console = CaptureConsole()
|
||||
monkeypatch.setattr(selection_module, "console", fake_console)
|
||||
responses: list[Any] = ["prod", ["dev", "stage"]]
|
||||
|
||||
async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> Any:
|
||||
return responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
selection_module, "prompt_for_selection", fake_prompt_for_selection
|
||||
)
|
||||
table = render_table_base("Environments")
|
||||
|
||||
single = await select_value_from_dict(sample_options, table)
|
||||
multiple = await select_value_from_dict(
|
||||
sample_options,
|
||||
table,
|
||||
number_selections=2,
|
||||
separator=",",
|
||||
)
|
||||
|
||||
assert single == "prod-value"
|
||||
assert multiple == ["dev-value", "stage-value"]
|
||||
assert len(fake_console.printed) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_selection_from_dict_menu_builds_table_and_returns_selected_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sample_options: dict[str, SelectionOption],
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_select_value_from_dict(
|
||||
*, selections: dict[str, SelectionOption], table: Table, **kwargs: Any
|
||||
) -> str:
|
||||
calls.append({"selections": selections, "table": table, **kwargs})
|
||||
return "prod-value"
|
||||
|
||||
monkeypatch.setattr(
|
||||
selection_module, "select_value_from_dict", fake_select_value_from_dict
|
||||
)
|
||||
|
||||
result = await get_selection_from_dict_menu(
|
||||
"Environments",
|
||||
sample_options,
|
||||
default_selection="prod",
|
||||
number_selections=1,
|
||||
cancel_key="q",
|
||||
)
|
||||
|
||||
assert result == "prod-value"
|
||||
assert calls[0]["selections"] is sample_options
|
||||
assert calls[0]["table"].title == "Environments"
|
||||
assert calls[0]["default_selection"] == "prod"
|
||||
assert calls[0]["cancel_key"] == "q"
|
||||
Reference in New Issue
Block a user