diff --git a/examples/argument_examples.py b/examples/argument_examples.py index b0ec775..38946c8 100644 --- a/examples/argument_examples.py +++ b/examples/argument_examples.py @@ -1,5 +1,6 @@ import asyncio from enum import Enum +from pathlib import Path from falyx import Falyx from falyx.action import Action @@ -21,13 +22,14 @@ async def test_args( service: str, place: Place = Place.NEW_YORK, region: str = "us-east-1", + path: Path | None = None, tag: str | None = None, verbose: bool | None = None, number: int | None = None, ) -> str: if verbose: - print(f"Deploying {service}:{tag}:{number} to {region} at {place}...") - return f"{service}:{tag}:{number} deployed to {region} at {place}" + print(f"Deploying {service}:{tag}:{number} to {region} at {place} from {path}...") + return f"{service}:{tag}:{number} deployed to {region} at {place} from {path}." def default_config(parser: CommandArgumentParser) -> None: @@ -52,6 +54,11 @@ def default_config(parser: CommandArgumentParser) -> None: help="Deployment region.", choices=["us-east-1", "us-west-2", "eu-west-1"], ) + parser.add_argument( + "--path", + type=Path, + help="Path to the configuration file.", + ) parser.add_argument( "--verbose", action="store_bool_optional", diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index 7c3ea0b..087b5ff 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -21,12 +21,14 @@ Key Features: - Columnar layout with automatic width scaling - Optional integration with `OptionsManager` for dynamic state toggling -Usage Example: +Example: + ``` bar = BottomBar(columns=3) bar.add_static("env", "ENV: dev") bar.add_toggle("d", "Debug", get_debug, toggle_debug) bar.add_value_tracker("attempts", "Retries", get_retry_count) bar.render() + ``` Used by Falyx to provide a persistent UI element showing toggles, system state, and runtime telemetry below the input prompt. diff --git a/falyx/completer.py b/falyx/completer.py index aac2369..002b4b7 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -17,6 +17,7 @@ Integrated with the `Falyx.prompt_session` to enhance the interactive experience from __future__ import annotations +import os import shlex from typing import TYPE_CHECKING, Iterable @@ -69,7 +70,9 @@ class FalyxCompleter(Completer): if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): # Suggest command keys and aliases - yield from self._suggest_commands(tokens[0] if tokens else "") + stub = tokens[0] if tokens else "" + suggestions = [c.text for c in self._suggest_commands(stub)] + yield from self._yield_lcp_completions(suggestions, stub) return # Identify command @@ -83,21 +86,10 @@ class FalyxCompleter(Completer): stub = "" if cursor_at_end_of_token else tokens[-1] try: - if not command.arg_parser: - return suggestions = command.arg_parser.suggest_next( parsed_args + ([stub] if stub else []), cursor_at_end_of_token ) - for suggestion in suggestions: - if suggestion.startswith(stub): - if len(suggestion.split()) > 1: - yield Completion( - f'"{suggestion}"', - start_position=-len(stub), - display=suggestion, - ) - else: - yield Completion(suggestion, start_position=-len(stub)) + yield from self._yield_lcp_completions(suggestions, stub) except Exception: return @@ -126,3 +118,42 @@ class FalyxCompleter(Completer): for key in keys: if key.upper().startswith(prefix): yield Completion(key, start_position=-len(prefix)) + + def _ensure_quote(self, text: str) -> str: + """ + Ensure the text is properly quoted for shell commands. + + Args: + text (str): The input text to quote. + + Returns: + str: The quoted text, suitable for shell command usage. + """ + if " " in text or "\t" in text: + return f'"{text}"' + return text + + def _yield_lcp_completions(self, suggestions, stub): + matches = [s for s in suggestions if s.startswith(stub)] + if not matches: + return + + lcp = os.path.commonprefix(matches) + + if len(matches) == 1: + yield Completion( + self._ensure_quote(matches[0]), + start_position=-len(stub), + display=matches[0], + ) + elif len(lcp) > len(stub) and not lcp.startswith("-"): + yield Completion(lcp, start_position=-len(stub), display=lcp) + for match in matches: + yield Completion( + self._ensure_quote(match), start_position=-len(stub), display=match + ) + else: + for match in matches: + yield Completion( + self._ensure_quote(match), start_position=-len(stub), display=match + ) diff --git a/falyx/falyx.py b/falyx/falyx.py index e4ab0b1..33f9de2 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -37,6 +37,7 @@ from prompt_toolkit.formatted_text import StyleAndTextTuples from prompt_toolkit.history import FileHistory from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.shortcuts import CompleteStyle from prompt_toolkit.validation import ValidationError from rich import box from rich.console import Console @@ -395,9 +396,12 @@ class Falyx: return for command in commands: usage, description, _ = command.help_signature - self.console.print(usage) - if description: - self.console.print(description) + self.console.print( + Padding( + Panel(usage, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) return self.console.print("[bold]help:[/bold]") @@ -559,6 +563,7 @@ class Falyx: history=self.history, multiline=False, completer=self._get_completer(), + complete_style=CompleteStyle.COLUMN, validator=CommandValidator(self, self._get_validator_error_message()), bottom_toolbar=self._get_bottom_bar_render(), key_bindings=self.key_bindings, @@ -1161,12 +1166,12 @@ class Falyx: This method parses CLI arguments, configures the runtime environment, and dispatches execution to the appropriate command mode: - • **list** - Show help output, optionally filtered by tag. - • **version** - Print the program version and exit. - • **preview** - Display a preview of the specified command without executing it. - • **run** - Execute a single command with parsed arguments and lifecycle hooks. - • **run-all** - Run all commands matching a tag concurrently (with default args). - • (default) - Launch the interactive Falyx menu loop. + - list - Show help output, optionally filtered by tag. + - version - Print the program version and exit. + - preview - Display a preview of the specified command without executing it. + - run - Execute a single command with parsed arguments and lifecycle hooks. + - run-all - Run all commands matching a tag concurrently (with default args). + - (default) - Launch the interactive Falyx menu loop. It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting, and supports an optional callback for post-parse setup. @@ -1202,8 +1207,10 @@ class Falyx: - For interactive sessions, this method falls back to `menu()`. Example: + ``` >>> flx = Falyx() >>> await flx.run() # Parses CLI args and dispatches appropriately + ``` """ if self.cli_args: diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index a1c0d71..c495ff8 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -49,6 +49,7 @@ from __future__ import annotations from collections import Counter, defaultdict from copy import deepcopy +from pathlib import Path from typing import Any, Iterable, Sequence from rich.console import Console @@ -1101,6 +1102,24 @@ class CommandArgumentParser: kwargs_dict[arg.dest] = parsed[arg.dest] return tuple(args_list), kwargs_dict + def _suggest_paths(self, stub: str) -> list[str]: + """Return filesystem path suggestions based on a stub.""" + path = Path(stub or ".").expanduser() + base_dir = path if path.is_dir() else path.parent + if not base_dir.exists(): + return [] + completions = [] + for child in base_dir.iterdir(): + name = str(child) + if child.is_dir(): + name += "/" + completions.append(name) + if stub and not path.is_dir(): + completions = [ + completion for completion in completions if completion.startswith(stub) + ] + return completions[:100] + def suggest_next( self, args: list[str], cursor_at_end_of_token: bool = False ) -> list[str]: @@ -1117,6 +1136,7 @@ class CommandArgumentParser: list[str]: List of suggested completions. """ + last = args[-1] if args else "" # Case 1: Next positional argument next_non_consumed_positional: Argument | None = None for state in self._last_positional_states.values(): @@ -1126,11 +1146,33 @@ class CommandArgumentParser: if next_non_consumed_positional: if next_non_consumed_positional.choices: + if ( + cursor_at_end_of_token + and last + and any( + str(choice).startswith(last) + for choice in next_non_consumed_positional.choices + ) + and next_non_consumed_positional.nargs in (1, "?", None) + ): + return [] return sorted( (str(choice) for choice in next_non_consumed_positional.choices) ) if next_non_consumed_positional.suggestions: + if ( + cursor_at_end_of_token + and last + and any( + str(suggestion).startswith(last) + for suggestion in next_non_consumed_positional.suggestions + ) + and next_non_consumed_positional.nargs in (1, "?", None) + ): + return [] return sorted(next_non_consumed_positional.suggestions) + if next_non_consumed_positional.type == Path: + return self._suggest_paths(args[-1] if args else "") consumed_dests = [ state.arg.dest @@ -1163,12 +1205,13 @@ class CommandArgumentParser: and next_to_last in self._keyword and next_to_last in remaining_flags ): - # If the last token is a mid-flag, suggest based on the previous flag arg = self._keyword[next_to_last] if arg.choices: - suggestions.extend(arg.choices) + suggestions.extend((str(choice) for choice in arg.choices)) elif arg.suggestions: - suggestions.extend(arg.suggestions) + suggestions.extend( + (str(suggestion) for suggestion in arg.suggestions) + ) else: possible_flags = [ flag @@ -1185,9 +1228,11 @@ class CommandArgumentParser: ): pass elif arg.choices: - suggestions.extend(arg.choices) + suggestions.extend((str(choice) for choice in arg.choices)) elif arg.suggestions: - suggestions.extend(arg.suggestions) + suggestions.extend((str(suggestion) for suggestion in arg.suggestions)) + elif arg.type == Path: + suggestions.extend(self._suggest_paths(".")) # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"]) elif next_to_last in self._keyword: arg = self._keyword[next_to_last] @@ -1204,7 +1249,7 @@ class CommandArgumentParser: ): pass elif arg.choices and last not in arg.choices and not cursor_at_end_of_token: - suggestions.extend(arg.choices) + suggestions.extend((str(choice) for choice in arg.choices)) elif ( arg.suggestions and last not in arg.suggestions @@ -1212,9 +1257,11 @@ class CommandArgumentParser: and any(suggestion.startswith(last) for suggestion in arg.suggestions) and not cursor_at_end_of_token ): - suggestions.extend(arg.suggestions) + suggestions.extend((str(suggestion) for suggestion in arg.suggestions)) elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: pass + elif arg.type == Path and not cursor_at_end_of_token: + suggestions.extend(self._suggest_paths(last)) else: suggestions.extend(remaining_flags) elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: diff --git a/falyx/validators.py b/falyx/validators.py index 60da09a..096693e 100644 --- a/falyx/validators.py +++ b/falyx/validators.py @@ -118,6 +118,10 @@ def words_validator( def word_validator(word: str) -> Validator: """Validator for specific word inputs.""" + if word.upper() == "N": + raise ValueError( + "The word 'N' is reserved for yes/no validation. Use yes_no_validator instead." + ) def validate(text: str) -> bool: if text.upper().strip() == "N": diff --git a/falyx/version.py b/falyx/version.py index dad0260..939e02b 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.79" +__version__ = "0.1.80" diff --git a/pyproject.toml b/pyproject.toml index 242fa68..2cbf02c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.79" +version = "0.1.80" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_completer/test_completer.py b/tests/test_completer/test_completer.py new file mode 100644 index 0000000..e5764ec --- /dev/null +++ b/tests/test_completer/test_completer.py @@ -0,0 +1,97 @@ +from types import SimpleNamespace + +import pytest +from prompt_toolkit.completion import Completion +from prompt_toolkit.document import Document + +from falyx.completer import FalyxCompleter + + +@pytest.fixture +def fake_falyx(): + fake_arg_parser = SimpleNamespace( + suggest_next=lambda tokens, end: ["--tag", "--name", "value with space"] + ) + fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser) + return SimpleNamespace( + exit_command=SimpleNamespace(key="X", aliases=["EXIT"]), + help_command=SimpleNamespace(key="H", aliases=["HELP"]), + history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]), + commands={"R": fake_command}, + _name_map={"R": fake_command, "RUN": fake_command, "X": fake_command}, + ) + + +def test_suggest_commands(fake_falyx): + completer = FalyxCompleter(fake_falyx) + completions = list(completer._suggest_commands("R")) + assert any(c.text == "R" for c in completions) + assert any(c.text == "RUN" for c in completions) + + +def test_suggest_commands_empty(fake_falyx): + completer = FalyxCompleter(fake_falyx) + completions = list(completer._suggest_commands("")) + assert any(c.text == "X" for c in completions) + assert any(c.text == "H" for c in completions) + + +def test_suggest_commands_no_match(fake_falyx): + completer = FalyxCompleter(fake_falyx) + completions = list(completer._suggest_commands("Z")) + assert not completions + + +def test_get_completions_no_input(fake_falyx): + completer = FalyxCompleter(fake_falyx) + doc = Document("") + results = list(completer.get_completions(doc, None)) + assert any(isinstance(c, Completion) for c in results) + assert any(c.text == "X" for c in results) + + +def test_get_completions_no_match(fake_falyx): + completer = FalyxCompleter(fake_falyx) + doc = Document("Z") + completions = list(completer.get_completions(doc, None)) + assert not completions + doc = Document("Z Z") + completions = list(completer.get_completions(doc, None)) + assert not completions + + +def test_get_completions_partial_command(fake_falyx): + completer = FalyxCompleter(fake_falyx) + doc = Document("R") + results = list(completer.get_completions(doc, None)) + assert any(c.text in ("R", "RUN") for c in results) + + +def test_get_completions_with_flag(fake_falyx): + completer = FalyxCompleter(fake_falyx) + doc = Document("R ") + results = list(completer.get_completions(doc, None)) + assert "--tag" in [c.text for c in results] + + +def test_get_completions_partial_flag(fake_falyx): + completer = FalyxCompleter(fake_falyx) + doc = Document("R --t") + results = list(completer.get_completions(doc, None)) + assert all(c.start_position <= 0 for c in results) + assert any(c.text.startswith("--t") or c.display == "--tag" for c in results) + + +def test_get_completions_bad_input(fake_falyx): + completer = FalyxCompleter(fake_falyx) + doc = Document('R "unclosed quote') + results = list(completer.get_completions(doc, None)) + assert results == [] + + +def test_get_completions_exception_handling(fake_falyx): + completer = FalyxCompleter(fake_falyx) + fake_falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0 + doc = Document("R --tag") + results = list(completer.get_completions(doc, None)) + assert results == [] diff --git a/tests/test_completer/test_lcp_completions.py b/tests/test_completer/test_lcp_completions.py new file mode 100644 index 0000000..9a9f1a1 --- /dev/null +++ b/tests/test_completer/test_lcp_completions.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +import pytest +from prompt_toolkit.document import Document + +from falyx.completer import FalyxCompleter + + +@pytest.fixture +def fake_falyx(): + fake_arg_parser = SimpleNamespace( + suggest_next=lambda tokens, end: ["AETHERWARP", "AETHERZOOM"] + ) + fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser) + return SimpleNamespace( + exit_command=SimpleNamespace(key="X", aliases=["EXIT"]), + help_command=SimpleNamespace(key="H", aliases=["HELP"]), + history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]), + commands={"R": fake_command}, + _name_map={"R": fake_command, "RUN": fake_command, "X": fake_command}, + ) + + +def test_lcp_completions(fake_falyx): + completer = FalyxCompleter(fake_falyx) + doc = Document("R A") + results = list(completer.get_completions(doc, None)) + assert any(c.text == "AETHER" for c in results) + assert any(c.text == "AETHERWARP" for c in results) + assert any(c.text == "AETHERZOOM" for c in results) + + +def test_lcp_completions_space(fake_falyx): + completer = FalyxCompleter(fake_falyx) + suggestions = ["London", "New York", "San Francisco"] + stub = "N" + completions = list(completer._yield_lcp_completions(suggestions, stub)) + assert any(c.text == '"New York"' for c in completions) diff --git a/tests/test_validators/test_command_validator.py b/tests/test_validators/test_command_validator.py new file mode 100644 index 0000000..0fd2d5e --- /dev/null +++ b/tests/test_validators/test_command_validator.py @@ -0,0 +1,42 @@ +from unittest.mock import AsyncMock + +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import CommandValidator + + +@pytest.mark.asyncio +async def test_command_validator_validates_command(): + fake_falyx = AsyncMock() + fake_falyx.get_command.return_value = (False, object(), (), {}) + validator = CommandValidator(fake_falyx, "Invalid!") + + await validator.validate_async(Document("valid")) + fake_falyx.get_command.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_command_validator_rejects_invalid_command(): + fake_falyx = AsyncMock() + fake_falyx.get_command.return_value = (False, None, (), {}) + validator = CommandValidator(fake_falyx, "Invalid!") + + with pytest.raises(ValidationError): + await validator.validate_async(Document("not_a_command")) + + with pytest.raises(ValidationError): + await validator.validate_async(Document("")) + + +@pytest.mark.asyncio +async def test_command_validator_is_preview(): + fake_falyx = AsyncMock() + fake_falyx.get_command.return_value = (True, None, (), {}) + validator = CommandValidator(fake_falyx, "Invalid!") + + await validator.validate_async(Document("?preview_command")) + fake_falyx.get_command.assert_awaited_once_with( + "?preview_command", from_validate=True + ) diff --git a/tests/test_validators/test_int_range_validator.py b/tests/test_validators/test_int_range_validator.py new file mode 100644 index 0000000..ac6d4cc --- /dev/null +++ b/tests/test_validators/test_int_range_validator.py @@ -0,0 +1,24 @@ +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import int_range_validator + + +def test_int_range_validator_accepts_valid_numbers(): + validator = int_range_validator(1, 10) + for valid in ["1", "5", "10"]: + validator.validate(Document(valid)) + + +@pytest.mark.parametrize("invalid", ["0", "11", "5.5", "hello", "-1", ""]) +def test_int_range_validator_rejects_invalid(invalid): + validator = int_range_validator(1, 10) + with pytest.raises(ValidationError): + validator.validate(Document(invalid)) + + +def test_int_range_validator_edge_cases(): + validator = int_range_validator(1, 10) + for valid in ["1", "10"]: + validator.validate(Document(valid)) diff --git a/tests/test_validators/test_key_validator.py b/tests/test_validators/test_key_validator.py new file mode 100644 index 0000000..eafafcb --- /dev/null +++ b/tests/test_validators/test_key_validator.py @@ -0,0 +1,18 @@ +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import key_validator + + +def test_key_validator_accepts_valid_keys(): + validator = key_validator(["A", "B", "Z"]) + for valid in ["A", "B", "Z"]: + validator.validate(Document(valid)) + + +@pytest.mark.parametrize("invalid", ["Y", "D", "C", "", "1", "AB", "ZB"]) +def test_key_validator_rejects_invalid(invalid): + validator = key_validator(["A", "B", "Z"]) + with pytest.raises(ValidationError): + validator.validate(Document(invalid)) diff --git a/tests/test_validators/test_multi_index_validator.py b/tests/test_validators/test_multi_index_validator.py new file mode 100644 index 0000000..2ae1000 --- /dev/null +++ b/tests/test_validators/test_multi_index_validator.py @@ -0,0 +1,73 @@ +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import MultiIndexValidator + + +def test_multi_index_validator_accepts_valid_indices(): + validator = MultiIndexValidator( + 1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" + ) + for valid in ["1,2,3", "2,3,4", "1,4,5"]: + validator.validate(Document(valid)) + + +def test_multi_index_validator_rejects_invalid_indices(): + validator = MultiIndexValidator( + 1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" + ) + with pytest.raises(ValidationError): + validator.validate(Document("A,!,F")) + with pytest.raises(ValidationError): + validator.validate(Document("0,6,7")) + with pytest.raises(ValidationError): + validator.validate(Document("1,2,2")) + + +def test_multi_index_validator_rejects_invalid_number_of_selections(): + validator = MultiIndexValidator( + 1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" + ) + with pytest.raises(ValidationError): + validator.validate(Document("1,2")) + with pytest.raises(ValidationError): + validator.validate(Document("1,2,3,4")) + validator = MultiIndexValidator( + 1, 5, number_selections=1, separator=",", allow_duplicates=False, cancel_key="C" + ) + validator.validate(Document("1")) + with pytest.raises(ValidationError): + validator.validate(Document("2,3")) + + +def test_multi_index_validator_cancel_key(): + validator = MultiIndexValidator( + 1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" + ) + validator.validate(Document("C")) + + +def test_multi_index_validator_cancel_alone(): + validator = MultiIndexValidator( + 1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" + ) + with pytest.raises(ValidationError): + validator.validate(Document("1,C")) + + +def test_multi_index_validator_empty_input(): + validator = MultiIndexValidator( + 1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" + ) + with pytest.raises(ValidationError): + validator.validate(Document("")) + + +def test_multi_index_validator_error_message_for_duplicates(): + validator = MultiIndexValidator( + 1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" + ) + with pytest.raises(ValidationError) as e: + validator.validate(Document("1,1,2")) + assert "Duplicate selection" in str(e.value) diff --git a/tests/test_validators/test_multi_key_validator.py b/tests/test_validators/test_multi_key_validator.py new file mode 100644 index 0000000..7e21220 --- /dev/null +++ b/tests/test_validators/test_multi_key_validator.py @@ -0,0 +1,105 @@ +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import MultiKeyValidator + + +def test_multi_key_validator_accepts_valid_keys(): + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=2, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + for valid in ["A,B", "B,C", "A,C"]: + validator.validate(Document(valid)) + + +def test_multi_key_validator_rejects_invalid_keys(): + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=2, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + with pytest.raises(ValidationError): + validator.validate(Document("D,E,F")) + with pytest.raises(ValidationError): + validator.validate(Document("A,B,A")) + with pytest.raises(ValidationError): + validator.validate(Document("A,B,C,D")) + + +def test_multi_key_validator_rejects_invalid_number_of_selections(): + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=2, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + with pytest.raises(ValidationError): + validator.validate(Document("A")) # Not enough selections + with pytest.raises(ValidationError): + validator.validate(Document("A,B,C")) # Too many selections + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=1, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + validator.validate(Document("A")) # Exactly one selection is valid + with pytest.raises(ValidationError): + validator.validate(Document("B,C")) # Too many selections + + +def test_multi_key_validator_cancel_key(): + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=2, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + validator.validate(Document("X")) + + +def test_multi_key_validator_cancel_alone(): + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=2, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + with pytest.raises(ValidationError): + validator.validate(Document("A,X")) + + +def test_multi_key_validator_empty_input(): + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=2, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + with pytest.raises(ValidationError): + validator.validate(Document("")) + + +def test_multi_key_validator_error_message_for_duplicates(): + validator = MultiKeyValidator( + ["A", "B", "C"], + number_selections=2, + separator=",", + allow_duplicates=False, + cancel_key="X", + ) + with pytest.raises(ValidationError) as e: + validator.validate(Document("A,A,B")) + assert "Duplicate selection" in str(e.value) diff --git a/tests/test_validators/test_word_validator.py b/tests/test_validators/test_word_validator.py new file mode 100644 index 0000000..45b9cfd --- /dev/null +++ b/tests/test_validators/test_word_validator.py @@ -0,0 +1,29 @@ +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import word_validator + + +def test_word_validator_accepts_valid_words(): + validator = word_validator("apple") + validator.validate(Document("apple")) + validator.validate(Document("N")) + + +def test_word_validator_accepts_case_insensitive(): + validator = word_validator("banana") + validator.validate(Document("BANANA")) + validator.validate(Document("banana")) + + +def test_word_validator_rejects_n(): + with pytest.raises(ValueError): + word_validator("N") + + +@pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"]) +def test_word_validator_rejects_invalid(invalid): + validator = word_validator("apple") + with pytest.raises(ValidationError): + validator.validate(Document(invalid)) diff --git a/tests/test_validators/test_words_validator.py b/tests/test_validators/test_words_validator.py new file mode 100644 index 0000000..9170d13 --- /dev/null +++ b/tests/test_validators/test_words_validator.py @@ -0,0 +1,18 @@ +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import words_validator + + +def test_words_validator_accepts_valid_words(): + validator = words_validator(["hello", "world", "falyx"]) + for valid in ["hello", "world", "falyx"]: + validator.validate(Document(valid)) # should not raise + + +@pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"]) +def test_words_validator_rejects_invalid(invalid): + validator = words_validator(["hello", "world", "falyx"]) + with pytest.raises(ValidationError): + validator.validate(Document(invalid)) diff --git a/tests/test_validators/test_yes_no_validator.py b/tests/test_validators/test_yes_no_validator.py new file mode 100644 index 0000000..02c73d0 --- /dev/null +++ b/tests/test_validators/test_yes_no_validator.py @@ -0,0 +1,18 @@ +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError + +from falyx.validators import yes_no_validator + + +def test_yes_no_validator_accepts_y_and_n(): + validator = yes_no_validator() + for valid in ["Y", "y", "N", "n"]: + validator.validate(Document(valid)) + + +@pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"]) +def test_yes_no_validator_rejects_invalid(invalid): + validator = yes_no_validator() + with pytest.raises(ValidationError): + validator.validate(Document(invalid))