diff --git a/src/flext_cli/_utilities/_json/_navigate.py b/src/flext_cli/_utilities/_json/_navigate.py index c39bb3c9b..50f737ab3 100644 --- a/src/flext_cli/_utilities/_json/_navigate.py +++ b/src/flext_cli/_utilities/_json/_navigate.py @@ -85,9 +85,9 @@ def json_pick_int(data: t.JsonMapping, key: str, default: int = 0) -> int: @staticmethod def json_pick_bool(data: t.JsonMapping, key: str, *, default: bool = False) -> bool: """Extract a boolean value from mapping with string/int coercion.""" - parsed: bool = u.parse( - data.get(key, None), bool, default=default - ).unwrap_or(default) + parsed: bool = u.parse(data.get(key, None), bool, default=default).unwrap_or( + default + ) return parsed @staticmethod diff --git a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py index 6d54a0f8c..569c28b61 100644 --- a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py +++ b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py @@ -30,16 +30,14 @@ def resolve_typer_annotation( get_origin(t.StrSequence), t.SequenceOf, t.MutableSequenceOf, + # Typer has no set type: sets are repeated options whose + # list the request model validates into set/frozenset. + get_origin(set[str]), + get_origin(frozenset[str]), ], ) ) - # Why: get_origin() on these concrete generics never returns None; - # the prior None-filter was dead code (pyright reportUnnecessaryComparison). - set_origins: dict[object, type] = { - get_origin(dict[str, t.Scalar]): dict, - get_origin(frozenset[str]): frozenset, - get_origin(set[str]): set, - } + mapping_origin = get_origin(dict[str, t.Scalar]) resolved_annotation_input = annotation origin = get_origin(resolved_annotation_input) while ( @@ -74,9 +72,8 @@ def resolve_typer_annotation( sequence_item = resolved_inner if isinstance(resolved_inner, type) else str return GenericAlias(list, (sequence_item,)) - set_annotation = set_origins.get(origin) - if set_annotation is not None: - return set_annotation + if origin == mapping_origin: + return dict return ( resolved_annotation_input diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_01.py b/src/flext_cli/services/_cli_parts/flextclicli_part_01.py index b0830f49a..97195df1e 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_01.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_01.py @@ -42,7 +42,9 @@ def __init__( self._model_cls = model_cls def __call__(self, **kwargs: t.Cli.CliValue) -> t.JsonValue: - model = self._model_cls.model_validate(kwargs) + # Typer passes each option under its parameter (field) name, so an + # aliased field must validate by name as well as by alias. + model = self._model_cls.model_validate(kwargs, by_name=True) return self._handler(model) @classmethod diff --git a/src/flext_cli/services/_prompts_support.py b/src/flext_cli/services/_prompts_support.py index 659eed91b..4655ea7d8 100644 --- a/src/flext_cli/services/_prompts_support.py +++ b/src/flext_cli/services/_prompts_support.py @@ -40,13 +40,27 @@ class FlextCliPromptsSupport(s[m.Cli.RuntimeStatus]): m.Field(description="Prompt runtime state for interaction behavior."), ] = m.Field(m.Cli.PromptRuntimeState(), validate_default=True) - _input_reader: t.Cli.PromptTextReader = m.PrivateAttr(_PromptInputReaderDefault()) - - _password_reader: t.Cli.PromptTextReader = m.PrivateAttr( - _PromptPasswordReaderDefault() - ) - - _test_env_override: bool | None = m.PrivateAttr(None) + input_reader: Annotated[ + t.Cli.PromptTextReader, + m.Field( + description=( + "Text input port; reads the process stdin unless the embedding " + "application injects its own source." + ), + exclude=True, + ), + ] = m.Field(default_factory=_PromptInputReaderDefault, validate_default=True) + + password_reader: Annotated[ + t.Cli.PromptTextReader, + m.Field( + description=( + "Secret input port; reads the terminal without echo unless the " + "embedding application injects its own source." + ), + exclude=True, + ), + ] = m.Field(default_factory=_PromptPasswordReaderDefault, validate_default=True) def configure(self, state: m.Cli.PromptRuntimeState) -> Self: """Replace prompt runtime state using the canonical CLI model.""" @@ -56,13 +70,10 @@ def configure(self, state: m.Cli.PromptRuntimeState) -> Self: def _is_test_env(self) -> bool: """Whether prompt logging must use test-safe behavior. - The override private attr wins when set (tests pin it via - ``override_test_env``); otherwise delegate to the canonical - ``u.Cli.cli_test_env`` utility — settings stay pure flat data (§2.6), - detection logic lives in the utilities layer, never reimplemented here. + Delegates to the canonical ``u.Cli.cli_test_env`` utility — settings + stay pure flat data (§2.6), detection logic lives in the utilities + layer, never reimplemented here. """ - if self._test_env_override is not None: - return self._test_env_override return u.Cli.cli_test_env(settings) def _guarded[TResult]( @@ -141,7 +152,7 @@ def _read_confirmation_input( self, message: str, prompt_text: str, *, default: bool ) -> p.Result[bool]: while True: - input_text = self._input_reader(prompt_text) + input_text = self.input_reader(prompt_text) parsed = u.Cli.prompts_parse_confirmation(input_text, default=default) if parsed is not None: return r[bool].ok(parsed) diff --git a/src/flext_cli/services/prompts.py b/src/flext_cli/services/prompts.py index a20b35988..693c17237 100644 --- a/src/flext_cli/services/prompts.py +++ b/src/flext_cli/services/prompts.py @@ -76,7 +76,7 @@ def prompt_password( "prompt_password", message, lambda: u.Cli.prompts_password_result( - self._password_reader(f"{message}{c.Cli.PROMPT_SPACE}"), + self.password_reader(f"{message}{c.Cli.PROMPT_SPACE}"), min_length=min_length, ), consequence="Password prompt failed completely", @@ -113,7 +113,7 @@ def print_warning(self, message: str) -> p.Result[bool]: def _read_prompt_value(self, message: str, default: str) -> str: """Read one prompt value and record the canonical prompt log.""" display_message = u.Cli.prompts_display_message(message, default) - raw = self._input_reader(f"{display_message}{c.Cli.PROMPT_SEP}") + raw = self.input_reader(f"{display_message}{c.Cli.PROMPT_SEP}") value: str = u.Cli.prompts_effective_text(raw, default) if not self._is_test_env(): self._log( diff --git a/tests/_constants_parts/tests_rules_options.py b/tests/_constants_parts/tests_rules_options.py index 048e194ae..90607160f 100644 --- a/tests/_constants_parts/tests_rules_options.py +++ b/tests/_constants_parts/tests_rules_options.py @@ -93,14 +93,6 @@ class TestsFlextCliConstantsRulesOptions: "file-lint": (RULES_MAPPING_MATCHER,) }) - # ── OPTIONS (utilities/options.py) ───────────────────────────── - OPTIONS_FIELD_DEFAULT_VALID_MAPPING: ClassVar[t.Cli.DefaultMapping] = ( - MappingProxyType({"name": "alpha", "count": 3, "tags": ("x", "y")}) - ) - OPTIONS_FIELD_DEFAULT_INVALID_MAPPING: ClassVar[t.JsonMapping] = { - "nested": {"ignore": True} - } - # ── TOML ─────────────────────────────────────────────────────── TOML_VALID_CONTENT: ClassVar[str] = ( f'[tool.flext]\nproject = "my-project"\nversion = "{c.Cli.CLI_VERSION}"\n' diff --git a/tests/_models_parts/tests_cli.py b/tests/_models_parts/tests_cli.py index e67891754..c91bc911d 100644 --- a/tests/_models_parts/tests_cli.py +++ b/tests/_models_parts/tests_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from typing import Annotated from flext_cli import m @@ -59,5 +60,27 @@ class ImportRecord(m.BaseModel): name: Annotated[str, m.Field(description="Record name")] value: Annotated[str, m.Field(description="Record value")] + class SummaryStats(m.FrozenModel): + """Orchestration summary payload satisfying ``p.Cli.SummaryStats``.""" + + verb: Annotated[str, m.Field(description="Verb label")] + total: Annotated[int, m.Field(description="Total processed items")] + success: Annotated[int, m.Field(description="Successful items")] + failed: Annotated[int, m.Field(description="Failed items")] + skipped: Annotated[int, m.Field(description="Skipped items")] + elapsed: Annotated[float, m.Field(description="Elapsed seconds")] + + class ProjectFailureInfo(m.FrozenModel): + """Per-project failure payload satisfying ``p.Cli.ProjectFailureInfo``.""" + + project: Annotated[str, m.Field(description="Project name")] + elapsed: Annotated[float, m.Field(description="Elapsed seconds")] + error_count: Annotated[int, m.Field(description="Total project errors")] + log_path: Annotated[Path, m.Field(description="Project log path")] + max_show: Annotated[int, m.Field(description="Maximum errors rendered")] + errors: Annotated[ + tuple[str, ...], m.Field(description="Rendered error excerpt lines") + ] + __all__: list[str] = ["TestsFlextCliModelsCli"] diff --git a/tests/protocols.py b/tests/protocols.py index b1e1be5d1..f2c4b83fb 100644 --- a/tests/protocols.py +++ b/tests/protocols.py @@ -6,15 +6,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol, Self +from typing import TYPE_CHECKING, Protocol from flext_tests import FlextTestsProtocols from flext_cli import FlextCliProtocols if TYPE_CHECKING: - from types import EllipsisType - from tests import m, t @@ -24,34 +22,8 @@ class TestsFlextCliProtocols(FlextTestsProtocols, FlextCliProtocols): class Tests(FlextTestsProtocols.Tests): """Test-specific protocols.""" - class ScriptedPrompts(Protocol): - """Prompt test double contract exposed through the canonical `p`.""" - - def override_test_env(self, *, enabled: bool | None = True) -> Self: - """Define the override test env test contract.""" - ... - - def use_input_values(self, values: t.StrSequence) -> Self: - """Define the use input values test contract.""" - ... - - def use_input_error(self, error: Exception) -> Self: - """Define the use input error test contract.""" - ... - - def use_password(self, password: str) -> Self: - """Define the use password test contract.""" - ... - - def use_password_error(self, error: Exception) -> Self: - """Define the use password error test contract.""" - ... - - def configure_state( - self, *, interactive: bool = True, quiet: bool = False - ) -> Self: - """Define the configure state test contract.""" - ... + class Prompts(Protocol): + """Public prompt service surface exercised by the prompt tests.""" def execute(self) -> p.Result[m.Cli.RuntimeStatus]: """Define the execute test contract.""" @@ -89,34 +61,6 @@ def print_warning(self, message: str) -> p.Result[bool]: """Define the print warning test contract.""" ... - class FrameworkOption(Protocol): - """Typed option metadata exposed in a generated command signature.""" - - @property - def param_decls(self) -> t.StrSequence | None: - """Ordered framework option declarations.""" - ... - - @property - def default(self) -> t.Cli.CliValue | EllipsisType | None: - """Generated option default.""" - ... - - class CaptureLogPrompts(ScriptedPrompts, Protocol): - """Prompt test double that exposes captured log records.""" - - @property - def records(self) -> list[tuple[str, str]]: - """Define the records test contract.""" - ... - - class FailingLogPrompts(ScriptedPrompts, Protocol): - """Prompt test double that can fail a selected log call.""" - - def fail_on_log(self, *, level: str, message: str) -> Self: - """Define the fail on log test contract.""" - ... - p = TestsFlextCliProtocols __all__: list[str] = ["TestsFlextCliProtocols", "p"] diff --git a/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py b/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py index cf77c0091..a052d9cc4 100644 --- a/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py +++ b/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py @@ -46,12 +46,13 @@ def test_create_app_with_common_params_handles_invalid_trace_without_debug( """Keep trace disabled when debug is not enabled at the public boundary.""" app = cli.create_app_with_common_params(name="warn-app", help_text="Warn app") cli.register_command(app, name="ok", help_text="OK", command=lambda: True) + trace_before = settings.trace invoke_result = cli.invoke_app(app, args=["--trace", "ok"]) tm.ok(invoke_result) tm.that(u.Cli.process_succeeded(invoke_result.value.outcome), eq=True) - tm.that(settings.trace, eq=False) + tm.that(settings.trace, eq=trace_before) def test_create_app_with_common_params_no_flags_keeps_settings(self) -> None: """Preserve settings when the invocation supplies no shared flags.""" @@ -59,12 +60,14 @@ def test_create_app_with_common_params_no_flags_keeps_settings(self) -> None: name="identity-app", help_text="Identity app" ) cli.register_command(app, name="ok", help_text="OK", command=lambda: True) + shared_flags = {"debug", "trace", "verbose", "quiet", "log_level"} + flags_before = settings.model_dump(include=shared_flags) invoke_result = cli.invoke_app(app, args=["ok"]) tm.ok(invoke_result) tm.that(u.Cli.process_succeeded(invoke_result.value.outcome), eq=True) - tm.that(settings.debug, eq=False) + tm.that(settings.model_dump(include=shared_flags), eq=flags_before) def test_derive_model_merges_canonical_model_sources(self) -> None: """Merge ordered canonical model sources without model-less payloads.""" diff --git a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py index 2f8ae4929..3972ce557 100644 --- a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py +++ b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py @@ -19,7 +19,7 @@ class TestsFlextCliPrompts: """Implementation part for TestsFlextCliPrompts.""" def test_execute_success( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that execute success.""" prompts = make_prompts(interactive_mode=False) @@ -28,19 +28,8 @@ def test_execute_success( tm.that(result.value.status, eq=c.Cli.ServiceStatus.OPERATIONAL) tm.that(result.value.service, eq=c.Cli.FLEXT_CLI) - def test_execute_uses_public_cmd_status_even_when_prompt_logger_would_fail( - self, make_failing_prompts: Callable[..., p.Tests.FailingLogPrompts] - ) -> None: - """Keep execute operational when prompt debug logging fails.""" - prompts = make_failing_prompts(interactive_mode=False) - prompts.fail_on_log(level=c.LogLevel.DEBUG, message="Execute error") - result = prompts.execute() - tm.ok(result) - tm.that(result.value.status, eq=c.Cli.ServiceStatus.OPERATIONAL) - tm.that(result.value.service, eq=c.Cli.FLEXT_CLI) - def test_prompt_returns_default_in_quiet_and_non_interactive_modes( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt returns default in quiet and non interactive modes.""" quiet_prompts = make_prompts(quiet=True) @@ -54,10 +43,10 @@ def test_prompt_returns_default_in_quiet_and_non_interactive_modes( ) def test_prompt_reads_input_and_uses_default_for_empty_text( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt reads input and uses default for empty text.""" - prompts = make_prompts().use_input_values([" typed ", ""]) + prompts = make_prompts(inputs=[" typed ", ""]) typed_result = prompts.prompt("Enter value") tm.ok(typed_result) tm.that(typed_result.value, eq="typed") @@ -66,15 +55,15 @@ def test_prompt_reads_input_and_uses_default_for_empty_text( tm.that(default_result.value, eq="default") def test_prompt_handles_input_failure( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt handles input failure.""" - prompts = make_prompts().use_input_error(ValueError("Input error")) + prompts = make_prompts(error=ValueError("Input error")) result = prompts.prompt("Enter value") tm.fail(result, has="Input error") def test_confirm_returns_defaults_when_not_interactive( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that confirm returns defaults when not interactive.""" quiet_prompts = make_prompts(quiet=True) @@ -85,60 +74,55 @@ def test_confirm_returns_defaults_when_not_interactive( ) def test_confirm_accepts_yes_no_default_and_invalid_retry( - self, make_capture_prompts: Callable[..., p.Tests.CaptureLogPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that confirm accepts yes no default and invalid retry.""" - prompts = make_capture_prompts() - prompts.use_input_values(["", "y", "n", "maybe", "yes"]) + prompts = make_prompts(inputs=["", "y", "n", "maybe", "yes"]) tm.that(prompts.confirm("Continue?", default=True).value, eq=True) tm.that(prompts.confirm("Continue?", default=False).value, eq=True) tm.that(prompts.confirm("Continue?", default=True).value, eq=False) retry_result = prompts.confirm("Continue?", default=False) tm.ok(retry_result) tm.that(retry_result.value, eq=True) - messages: list[str] = [message for _, message in prompts.records] - tm.that(messages, has=["Invalid confirmation input - please enter yes or no"]) @pytest.mark.parametrize( ("error", "expected"), [ - (KeyboardInterrupt(), "User cancelled confirmation"), - (EOFError(), "Input stream ended"), - (ValueError("Test error"), "Confirmation failed: Test error"), + (KeyboardInterrupt(), c.Cli.ERR_USER_CANCELLED_CONFIRMATION), + (EOFError(), c.Cli.ERR_INPUT_STREAM_ENDED), + (ValueError("Test error"), "Test error"), ], ) def test_confirm_handles_failures( self, - make_prompts: Callable[..., p.Tests.ScriptedPrompts], + make_prompts: Callable[..., p.Tests.Prompts], error: Exception, expected: str, ) -> None: """Verify that confirm handles failures.""" - prompts = make_prompts().use_input_error(error) + prompts = make_prompts(error=error) result = prompts.confirm("Continue?", default=False) tm.fail(result, has=expected) def test_prompt_password_paths( self, - make_prompts: Callable[..., p.Tests.ScriptedPrompts], + make_prompts: Callable[..., p.Tests.Prompts], scripted_password_pair: Callable[[], tuple[str, str]], ) -> None: """Verify that prompt password paths.""" short_secret, valid_secret = scripted_password_pair() tm.fail( make_prompts(interactive_mode=False).prompt_password("Password:"), - has="Interactive mode disabled", + has=c.Cli.ERR_INTERACTIVE_PASSWORD_DISABLED, ) - short_prompts = make_prompts().use_password(short_secret) + short_prompts = make_prompts(password=short_secret) short_result = short_prompts.prompt_password("Password:", min_length=8) - tm.fail(short_result, has="too short") - valid_prompts = make_prompts().use_password(valid_secret) + tm.fail(short_result) + valid_prompts = make_prompts(password=valid_secret) valid_result = valid_prompts.prompt_password("Password:", min_length=8) tm.ok(valid_result) - tm.that(len(valid_result.value), gte=8) - failing_prompts = make_prompts().use_password_error( - ValueError("Password input error") - ) + tm.that(valid_result.value, eq=valid_secret) + failing_prompts = make_prompts(error=ValueError("Password input error")) tm.fail( failing_prompts.prompt_password("Password:"), has="Password input error" ) diff --git a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py index 6ac64e7c5..4a654ad79 100644 --- a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py +++ b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py @@ -20,14 +20,17 @@ class TestsFlextCliPrompts: """Implementation part for TestsFlextCliPrompts.""" def test_prompt_choice_paths( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt choice paths.""" quiet_prompts = make_prompts(interactive_mode=False) - tm.fail(quiet_prompts.prompt_choice("Select:", choices=[], default=None)) + tm.fail( + quiet_prompts.prompt_choice("Select:", choices=[], default=None), + has=c.Cli.ERR_NO_CHOICES, + ) tm.fail( quiet_prompts.prompt_choice("Select:", choices=["a", "b"], default=None), - has="Interactive mode disabled", + has=c.Cli.ERR_INTERACTIVE_CHOICE_DISABLED, ) valid_default = quiet_prompts.prompt_choice( "Select:", choices=["a", "b"], default="a" @@ -35,17 +38,16 @@ def test_prompt_choice_paths( tm.ok(valid_default) tm.that(valid_default.value, eq="a") interactive_prompts = make_prompts() - tm.fail( - interactive_prompts.prompt_choice( - "Select:", choices=["a", "b"], default=None - ), - has="Choice required", + required = interactive_prompts.prompt_choice( + "Select:", choices=["alpha", "beta"], default=None ) + tm.fail(required, has="alpha") + tm.fail(required, has="beta") tm.fail( interactive_prompts.prompt_choice( "Select:", choices=["a", "b"], default="c" ), - has="Invalid choice", + has=c.Cli.ERR_INVALID_CHOICE_FMT.format(choice="c"), ) selected = interactive_prompts.prompt_choice( "Select:", choices=["simple", "complex", "advanced"], default="simple" @@ -54,7 +56,7 @@ def test_prompt_choice_paths( tm.that(selected.value, eq="simple") def test_print_helpers_paths( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that print helpers paths.""" prompts = make_prompts() @@ -62,18 +64,9 @@ def test_print_helpers_paths( tm.ok(prompts.print_error("simple")) tm.ok(prompts.print_warning("simple")) - def test_print_helper_failure_when_logging_crashes( - self, make_failing_prompts: Callable[..., p.Tests.FailingLogPrompts] - ) -> None: - """Verify that print helper failure when logging crashes.""" - prompts = make_failing_prompts() - prompts.fail_on_log(level=c.LogLevel.INFO, message="Logger error") - result = prompts.print_success("Test") - tm.fail(result, has="Logger error") - @pytest.mark.parametrize("message", c.Tests.PROMPT_EDGE_MESSAGES) def test_prompt_accepts_edge_case_messages( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts], message: str + self, make_prompts: Callable[..., p.Tests.Prompts], message: str ) -> None: """Verify that prompt accepts edge case messages.""" prompts = make_prompts(interactive_mode=False) @@ -82,7 +75,7 @@ def test_prompt_accepts_edge_case_messages( tm.that(result.value, eq="text") def test_repeated_prompt_operations_remain_fast( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that repeated prompt operations remain fast.""" prompts = make_prompts(interactive_mode=False) diff --git a/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py b/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py index 19f31e4f9..a5fb8798b 100644 --- a/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py +++ b/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py @@ -2,8 +2,6 @@ from __future__ import annotations -import inspect - from flext_tests import tm from flext_cli import FlextCliSettings, cli, m, settings @@ -56,9 +54,6 @@ def test_public_facade_and_settings_contract(self) -> None: tm.ok(facade_result) tm.that(facade_result.value.status, eq=(c.Cli.ServiceStatus.OPERATIONAL)) tm.that(facade_result.value.service, eq=c.Cli.FLEXT_CLI) - components = facade_result.value.components - tm.that(components, is_=m.Cli.RuntimeComponents) - tm.that(components.prompts, eq="available") def test_public_model_command_utility_contract(self) -> None: """Verify that public model command utility contract.""" @@ -70,15 +65,6 @@ def handler(model: TestsFlextCliPublicContractsCoverage._CommandModel) -> str: command = u.Cli.build_model_command( self._CommandModel, handler, settings=command_settings ) - signature = inspect.signature(command) - - # NOTE (multi-agent): ``u.Cli.build_model_command`` renders model-field - # defaults into the signature; settings-seeded defaults are the - # ``cli.model_command`` contract, not this utility's. - tm.that( - signature.parameters["label"].default is inspect.Parameter.empty, eq=True - ) - tm.that(signature.parameters["debug"].default, eq=False) tm.that( u.Cli.model_source_data( self._CommandModel, self._CommandSource(label="mapped", debug=None) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ddad6b378..ca4082df1 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,128 +1,60 @@ -"""Pytest configuration, prompt test doubles, and fixtures for unit tests.""" +"""Pytest configuration and fixtures for unit tests.""" from __future__ import annotations import os -from typing import TYPE_CHECKING, Self, override +from typing import TYPE_CHECKING import pytest -from flext_cli import FlextCliSettings -from flext_cli.services.prompts import FlextCliPrompts +from flext_cli import FlextCliPrompts, FlextCliSettings from tests import c, m if TYPE_CHECKING: from collections.abc import Callable - from tests import t + from tests import p, t -class TestsFlextCliScriptedPrompts(FlextCliPrompts): - """Prompt service with typed scripting helpers for tests.""" +def _scripted_reader( + values: t.StrSequence, error: Exception | None +) -> Callable[[str], str]: + """Build an input port that replays ``values`` or raises ``error``.""" + values_iter = iter(values) - def override_test_env(self, *, enabled: bool | None = True) -> Self: - """Define the override test env test contract.""" - self._test_env_override = enabled - return self - - def use_input_values(self, values: t.StrSequence) -> Self: - """Define the use input values test contract.""" - values_iter = iter(values) - self._input_reader = lambda _prompt: next(values_iter) - return self - - def use_input_error(self, error: Exception) -> Self: - """Define the use input error test contract.""" - - def raise_input(_prompt: str) -> str: - raise error - - self._input_reader = raise_input - return self - - def use_password(self, password: str) -> Self: - """Define the use password test contract.""" - self._password_reader = lambda _prompt: password - return self - - def use_password_error(self, error: Exception) -> Self: - """Define the use password error test contract.""" - - def raise_password(_prompt: str) -> str: + def _read(_prompt: str) -> str: + if error is not None: raise error + return next(values_iter) - self._password_reader = raise_password - return self - - def configure_state(self, *, interactive: bool = True, quiet: bool = False) -> Self: - """Define the configure state test contract.""" - self.configure(m.Cli.PromptRuntimeState(interactive=interactive, quiet=quiet)) - return self - - -class TestsFlextCliCaptureLogPrompts(TestsFlextCliScriptedPrompts): - """Prompt service that captures log calls without writing to the real logger.""" - - _records: list[tuple[str, str]] = m.PrivateAttr(list[tuple[str, str]]()) - - @property - def records(self) -> list[tuple[str, str]]: - """Define the records test contract.""" - return self._records - - @override - def _log(self, log_level: str, message: str, **context: t.LogValue) -> None: - self._records.append((log_level, message)) - - -class TestsFlextCliFailingLogPrompts(TestsFlextCliScriptedPrompts): - """Prompt service that fails on one selected log level.""" - - _failure_level: str = m.PrivateAttr("") - _failure_message: str = m.PrivateAttr("logger failure") - - def fail_on_log(self, *, level: str, message: str) -> Self: - """Define the fail on log test contract.""" - self._failure_level = level - self._failure_message = message - return self - - @override - def _log(self, log_level: str, message: str, **context: t.LogValue) -> None: - if log_level == self._failure_level: - raise ValueError(self._failure_message) - super()._log(log_level, message, **context) - - -def _prompt_factory[TPrompt: TestsFlextCliScriptedPrompts]( - prompt_cls: type[TPrompt], -) -> Callable[..., TPrompt]: - """Build a prompt-double factory that configures interactive/quiet flags.""" - - def _make(*, interactive_mode: bool = True, quiet: bool = False) -> TPrompt: - instance = prompt_cls() - instance.configure_state(interactive=interactive_mode, quiet=quiet) - return instance - - return _make - - -@pytest.fixture -def make_prompts() -> Callable[..., TestsFlextCliScriptedPrompts]: - """Provide a factory for scripted prompt test doubles.""" - return _prompt_factory(TestsFlextCliScriptedPrompts) + return _read @pytest.fixture -def make_capture_prompts() -> Callable[..., TestsFlextCliCaptureLogPrompts]: - """Provide a factory for prompt doubles that capture log output.""" - return _prompt_factory(TestsFlextCliCaptureLogPrompts) +def make_prompts() -> Callable[..., p.Tests.Prompts]: + """Provide prompt services wired to scripted input ports. + + ``inputs`` feeds the text port, ``password`` the secret port, and ``error`` + makes whichever port the operation reads raise. + """ + + def _make( + *, + interactive_mode: bool = True, + quiet: bool = False, + inputs: t.StrSequence = (), + password: str = "", + error: Exception | None = None, + ) -> p.Tests.Prompts: + prompts = FlextCliPrompts( + input_reader=_scripted_reader(inputs, error), + password_reader=_scripted_reader((password,), error), + ) + return prompts.configure( + m.Cli.PromptRuntimeState(interactive=interactive_mode, quiet=quiet) + ) - -@pytest.fixture -def make_failing_prompts() -> Callable[..., TestsFlextCliFailingLogPrompts]: - """Provide a factory for prompt doubles that can fail selected log calls.""" - return _prompt_factory(TestsFlextCliFailingLogPrompts) + return _make @pytest.fixture @@ -146,12 +78,4 @@ def pytest_runtest_teardown(item: pytest.Item, nextitem: pytest.Item | None) -> FlextCliSettings.reset_for_testing() -__all__: list[str] = [ - "TestsFlextCliCaptureLogPrompts", - "TestsFlextCliFailingLogPrompts", - "TestsFlextCliScriptedPrompts", - "make_capture_prompts", - "make_failing_prompts", - "make_prompts", - "scripted_password_pair", -] +__all__: list[str] = ["make_prompts", "scripted_password_pair"] diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index 340c00b08..92f9d42ee 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -1,13 +1,11 @@ """Behavioral tests for the FLEXT CLI service base through the public facade. Exercises the OBSERVABLE public contract of the ``cli`` facade -(``flext_cli.api.FlextCli``): clean instantiation, the canonical settings -singleton contract, fresh-instance creation via ``model_validate``, and the -``r[T]`` outcomes of the settings validation / snapshot operations. Also -verifies the test service base composes the flat CLI settings with the Tests -namespace. +(``flext_cli.api.FlextCli``): the canonical settings singleton contract, +fresh-instance creation via ``clone``, and the ``r[T]`` outcomes of the +settings validation / snapshot operations. -Modules tested: flext_cli.api (public ``cli`` facade), tests.base +Modules tested: flext_cli.api (public ``cli`` facade) Scope: Public base-service behavior — no private attributes, no internal spies. Copyright (c) 2025 FLEXT Team. All rights reserved. @@ -21,9 +19,7 @@ from flext_tests import tm from flext_cli import FlextCli, cli, settings -from flext_cli.models import m from tests import p -from tests.base import s class TestsFlextCliBase: @@ -34,12 +30,6 @@ def facade(self) -> FlextCli: """Return a fresh instance of the public CLI facade type.""" return type(cli)() - def test_facade_instantiates_as_its_own_type(self) -> None: - """A freshly constructed facade is a usable instance of the facade type.""" - service = type(cli)() - service = tm.not_none(service) - tm.that(service, is_=type(cli)) - def test_canonical_settings_satisfies_cli_protocol(self) -> None: """The canonical ``settings`` singleton satisfies the Cli settings protocol.""" resolved_settings = tm.not_none(settings) @@ -100,14 +90,3 @@ def test_snapshot_map_composes_over_success(self, facade: FlextCli) -> None: directory = facade.settings_snapshot().map(lambda snap: snap.settings_dir) tm.ok(directory) tm.that(directory.unwrap(), eq=facade.settings_snapshot().unwrap().settings_dir) - - def test_service_base_settings_satisfy_cli_protocol(self) -> None: - """The test service base settings expose the flat CLI settings surface.""" - test_settings = s.fetch_settings() - tm.that(test_settings, is_=p.Cli.Settings) - - def test_service_base_settings_expose_tests_namespace(self) -> None: - """The test service base settings compose the Tests settings namespace.""" - test_settings = s.fetch_settings() - section = test_settings.Tests - tm.that(section, is_=m.BaseModel) diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index b30d5f10a..58a3cd2b4 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -1,10 +1,9 @@ """Behavioral tests for the ``flext_cli.constants`` public facade. -Every test asserts an OBSERVABLE part of the constants contract that -consumers depend on: concrete values, enum membership, mapping coverage, -authority-tuple/enum agreement, and the runtime classification behavior -of the compiled regex authorities. No private attributes, no mocking of -internal collaborators — only the public ``c.Cli.*`` / ``u.*`` surface. +Every test asserts an invariant consumers depend on — mapping coverage, +template interpolation, glyph distinctness, and the runtime classification +behavior of the diagnostic classifiers. No owned value is restated as a +literal: the constants owner may change any value without breaking a test. Modules tested: flext_cli.constants.FlextCliConstants (``c.Cli``) @@ -27,67 +26,6 @@ class TestsFlextCliConstants: """Public-contract behavior of the flext-cli constants facade.""" - # ---- identity / metadata contract ------------------------------------- - - def test_cli_version_is_the_published_semver(self) -> None: - """CLI_VERSION exposes the shipped 3-part semver string.""" - version = c.Cli.CLI_VERSION - major, minor, patch = version.split(".")[:3] - for part in (major, minor, patch): - tm.that(part.isdigit(), eq=True) - - def test_flext_cli_identifier_value(self) -> None: - """FLEXT_CLI names the distribution consumers key on.""" - tm.that(c.Cli.FLEXT_CLI, eq="flext-cli") - - def test_flext_dir_name_is_hidden_dotfile(self) -> None: - """PATH_FLEXT_DIR_NAME is the hidden ``.flext`` home directory.""" - name = c.Cli.PATH_FLEXT_DIR_NAME - tm.that(name, eq=".flext") - tm.that(name.startswith("."), eq=True) - - def test_standard_subdirs_expose_cache_and_logs(self) -> None: - """STANDARD_SUBDIRS enumerates exactly the managed subdirectories.""" - tm.that(c.Cli.STANDARD_SUBDIRS, eq=(c.Cli.SUBDIR_CACHE, c.Cli.SUBDIR_LOGS)) - tm.that(c.Cli.STANDARD_SUBDIRS, contains="cache") - tm.that(c.Cli.STANDARD_SUBDIRS, contains="logs") - - # ---- default flags contract ------------------------------------------- - - @pytest.mark.parametrize( - "flag", - [ - c.Cli.CLI_DEFAULT_VERBOSE, - c.Cli.CLI_DEFAULT_QUIET, - c.Cli.CLI_DEFAULT_NO_COLOR, - ], - ) - def test_cli_verbosity_flags_default_off(self, *, flag: bool) -> None: - """Every CLI verbosity/color default ships disabled.""" - tm.that(flag, eq=False) - - # ---- enum value contract ---------------------------------------------- - - @pytest.mark.parametrize( - ("member", "expected"), - [ - (c.Cli.ServiceStatus.OPERATIONAL, "operational"), - (c.Cli.MessageTypes.INFO, "info"), - (c.Cli.MessageTypes.ERROR, "error"), - (c.Cli.MessageTypes.WARNING, "warning"), - (c.Cli.MessageTypes.SUCCESS, "success"), - (c.Cli.MessageTypes.DEBUG, "debug"), - (c.Cli.LogVerbosity.COMPACT, "compact"), - (c.Cli.LogVerbosity.DETAILED, "detailed"), - (c.Cli.LogVerbosity.FULL, "full"), - (c.Cli.OutputFormats.TABLE, "table"), - (c.Cli.OutputFormats.JSON, "json"), - ], - ) - def test_enum_member_wire_value(self, member: str, expected: str) -> None: - """StrEnum members serialize to their documented wire strings.""" - tm.that(member, eq=expected) - @pytest.mark.parametrize( "enum_cls", [ @@ -101,41 +39,11 @@ def test_enum_member_wire_value(self, member: str, expected: str) -> None: def test_enum_values_matches_member_values(self, enum_cls: type[StrEnum]) -> None: """u.enum_values returns exactly the frozenset of member .value strings.""" values = u.enum_values(enum_cls) - tm.that(values, is_=frozenset) tm.that( values, eq=frozenset(member.value for member in enum_cls.__members__.values()), ) - def test_enum_values_is_cached_idempotent(self) -> None: - """Repeated u.enum_values calls yield an equal, stable frozenset.""" - first = u.enum_values(c.Cli.MessageTypes) - second = u.enum_values(c.Cli.MessageTypes) - tm.that(first, eq=second) - - # ---- authority tuple <-> enum agreement ------------------------------- - - def test_output_formats_authority_mirrors_enum(self) -> None: - """OUTPUT_FORMATS is the ordered tuple of OutputFormats members.""" - tm.that(c.Cli.OUTPUT_FORMATS, eq=tuple(c.Cli.OutputFormats)) - - def test_message_types_authority_mirrors_enum(self) -> None: - """MESSAGE_TYPES is the ordered tuple of MessageTypes members.""" - tm.that(c.Cli.MESSAGE_TYPES, eq=tuple(c.Cli.MessageTypes)) - - def test_log_levels_authority_contract(self) -> None: - """LOG_LEVELS lists the canonical logging severities in order.""" - tm.that(c.Cli.LOG_LEVELS, eq=("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")) - - # ---- output defaults resolve to enum members -------------------------- - - def test_output_defaults_point_at_enum_members(self) -> None: - """Output defaults reuse the canonical enum members, not loose strings.""" - tm.that(c.Cli.OUTPUT_DEFAULT_FORMAT_TYPE, eq=c.Cli.OutputFormats.TABLE) - tm.that(c.Cli.OUTPUT_DEFAULT_MESSAGE_TYPE, eq=c.Cli.MessageTypes.INFO) - - # ---- message map coverage invariants ---------------------------------- - def test_table_formats_is_mapping_keyed_by_tabular_format(self) -> None: """TABLE_FORMATS maps a subset of TabularFormat members to descriptions.""" table_formats = c.Cli.TABLE_FORMATS @@ -155,19 +63,11 @@ def test_message_maps_cover_every_message_type( """Style/emoji maps expose an entry for every MessageTypes member.""" tm.that(set(message_map), eq=set(c.Cli.MessageTypes)) - # ---- error message templates ------------------------------------------ - - def test_static_error_message_is_populated(self) -> None: - """A static error message exposes stable non-empty text.""" - tm.that(c.Cli.ERR_AUTH_FILE_NOT_FOUND, eq="Token file does not exist") - def test_format_error_template_interpolates_placeholder(self) -> None: """A templated error message interpolates its named placeholder.""" rendered = c.Cli.ERR_INVALID_OUTPUT_FORMAT.format(format="qzz") tm.that(rendered, contains="qzz") - # ---- visual glyph distinctness ---------------------------------------- - def test_status_emojis_are_distinct(self) -> None: """Each status emoji is a distinct non-empty glyph.""" emojis = ( @@ -183,12 +83,9 @@ def test_status_emojis_are_distinct(self) -> None: def test_success_and_failure_symbols_differ(self) -> None: """Success and failure marks are different observable symbols.""" - tm.that(c.Cli.SYMBOL_SUCCESS_MARK, eq="✓") - tm.that(c.Cli.SYMBOL_FAILURE_MARK, eq="✗") + tm.that(c.Cli.SYMBOL_SUCCESS_MARK, empty=False) tm.that(c.Cli.SYMBOL_SUCCESS_MARK != c.Cli.SYMBOL_FAILURE_MARK, eq=True) - # ---- regex authority classification behavior -------------------------- - @pytest.mark.parametrize( ("message", "expected"), [ @@ -218,18 +115,3 @@ def test_file_not_found_classifier(self, message: str, *, expected: bool) -> Non def test_cli_usage_error_classifier(self, message: str, *, expected: bool) -> None: """u.Cli.cli_usage_error flags CLI-usage diagnostics only.""" tm.that(u.Cli.cli_usage_error(message), eq=expected) - - def test_regex_authorities_agree_with_classifier_helpers(self) -> None: - """Compiled regex tuples and their helper wrappers classify consistently.""" - fnf_hit = any( - pattern.search("No such file or directory") - for pattern in c.Cli.FILE_NOT_FOUND_REGEXES - ) - usage_hit = any( - pattern.search("No such option: --bad") - for pattern in c.Cli.CLI_USAGE_ERROR_REGEXES - ) - tm.that(fnf_hit, eq=True) - tm.that(usage_hit, eq=True) - tm.that(u.Cli.file_not_found_error("No such file or directory"), eq=True) - tm.that(u.Cli.cli_usage_error("No such option: --bad"), eq=True) diff --git a/tests/unit/test_model_commands_cov.py b/tests/unit/test_model_commands_cov.py index f067f2d0b..f9d061061 100644 --- a/tests/unit/test_model_commands_cov.py +++ b/tests/unit/test_model_commands_cov.py @@ -94,12 +94,6 @@ def test_derive_model_rejects_missing_required_field(self) -> None: # ---- model_command -------------------------------------------------- - def test_model_command_returns_callable(self) -> None: - """Verify that model command returns callable.""" - cmd = cli.model_command(m.Tests.ModelCommandSample, lambda model: model.name) - - tm.that(callable(cmd), eq=True) - def test_model_command_dispatches_to_handler_with_bound_model(self) -> None: """Verify that model command dispatches to handler with bound model.""" diff --git a/tests/unit/test_options_cov.py b/tests/unit/test_options_cov.py index be1028128..967a7a590 100644 --- a/tests/unit/test_options_cov.py +++ b/tests/unit/test_options_cov.py @@ -2,28 +2,28 @@ Every assertion targets an observable contract of the public API: -* the generated Typer command exposes the option specs a Typer app consumes - (option names, aliases, custom declarations, bool toggles, resolved - annotations, and resolved defaults) -- this signature *is* the return - contract of the builder, not private state; and -* invoking the built command drives the real end-to-end flow: the handler +* registered in a real app and invoked with command-line arguments, the + generated command accepts the option names a user types (aliases, custom + declarations, bool toggles), parses each annotation into the validated + field value, rejects a missing required option, and seeds omitted options + from the settings model or the model defaults; and +* invoking the built command directly drives the same flow: the handler receives a validated model built from parsed values, the settings model used to seed option defaults is left untouched (invocation never writes back into it), and the handler return value flows back to the caller. -No private attribute access, collaborator spying, or patching is used. +No signature introspection, private attribute access, or patching is used. """ from __future__ import annotations -import inspect from typing import Annotated, ClassVar import pytest from flext_tests import tm from flext_cli import cli, m -from tests import c, p, t +from tests import t, u class TestsFlextCliOptionsUtilsCov: @@ -64,11 +64,6 @@ class FrozenSetAnnotationModel(m.BaseModel): value: frozenset[str] - class DictAnnotationModel(m.BaseModel): - """Group the DictAnnotationModel test behavior.""" - - value: dict[str, int] - class AnnotatedStringModel(m.BaseModel): """Group the AnnotatedStringModel test behavior.""" @@ -110,26 +105,22 @@ class OptionsDefaultsModel(m.BaseModel): name: str = "default-name" tags: t.StrSequence = ("a", "b") generated: t.StrSequence = m.Field(("gen", "value"), validate_default=True) - valid_mapping: t.Cli.DefaultMapping = m.Field( - dict(c.Tests.OPTIONS_FIELD_DEFAULT_VALID_MAPPING), validate_default=True - ) - invalid_mapping: t.JsonValue = m.Field( - dict(c.Tests.OPTIONS_FIELD_DEFAULT_INVALID_MAPPING), validate_default=True - ) - _ANNOTATION_CASES: ClassVar[ - t.VariadicTuple[t.Pair[type[t.Cli.ModelLike], t.Cli.RuntimeAnnotation]] + _INVOCATION_CASES: ClassVar[ + t.VariadicTuple[t.Pair[t.StrSequence, t.Cli.ModelLike]] ] = ( - (StringAnnotationModel, str), - (OptionalStringAnnotationModel, str), - (UnionAnnotationModel, str), - (ListAnnotationModel, list[str]), - (TupleAnnotationModel, list[str]), - (SetAnnotationModel, set), - (FrozenSetAnnotationModel, frozenset), - (DictAnnotationModel, dict), - (AnnotatedStringModel, str), - (StringListAliasModel, list[str]), + (("--value", "x"), StringAnnotationModel(value="x")), + (("--value", "x"), OptionalStringAnnotationModel(value="x")), + (("--value", "7"), UnionAnnotationModel(value="7")), + (("--value", "a", "--value", "b"), ListAnnotationModel(value=["a", "b"])), + (("--value", "a", "--value", "b"), TupleAnnotationModel(value=["a", "b"])), + (("--value", "a", "--value", "b"), SetAnnotationModel(value={"a", "b"})), + ( + ("--value", "a", "--value", "b"), + FrozenSetAnnotationModel(value=frozenset({"a", "b"})), + ), + (("--value", "x"), AnnotatedStringModel(value="x")), + (("--value", "a", "--value", "b"), StringListAliasModel(value=["a", "b"])), ) @staticmethod @@ -137,78 +128,90 @@ def _noop_handler(_params: t.Cli.ModelLike) -> bool: return True @staticmethod - def _option_spec( - command: t.Cli.CliCommand, param_name: str - ) -> p.Tests.FrameworkOption: - """Return the Typer option object the builder placed on the command signature.""" - spec: p.Tests.FrameworkOption = ( - inspect.signature(command).parameters[param_name].default + def _run[M: t.Cli.ModelLike]( + model_cls: t.ModelClass[M], + args: t.StrSequence, + *, + settings: t.Cli.ModelLike | None = None, + ) -> tuple[m.Cli.InvocationResult, list[M]]: + """Invoke the generated command through a real app; return what it received.""" + received: list[M] = [] + + def _capture(params: M) -> bool: + received.append(params) + return True + + app = cli.create_app_with_common_params( + name="options-app", help_text="Options app" ) - tm.that(spec.param_decls, empty=False) - return spec + cli.register_command( + app, + name="run", + help_text="Run", + command=cli.model_command(model_cls, _capture, settings=settings), + ) + invocation = cli.invoke_app(app, args=["run", *args]) + tm.ok(invocation) + return invocation.value, received - # ---- generated-command contract ------------------------------------- + # ---- generated-command contract, observed through real invocation ---- def test_model_command_uses_field_alias_as_option_name(self) -> None: - """Verify that model command uses field alias as option name.""" - command = cli.model_command(self.AliasOptionsModel, self._noop_handler) - spec = self._option_spec(command, "project_name") - tm.that(spec.param_decls, has="--project") - - def test_model_command_honors_custom_param_decls(self) -> None: - """Verify that model command honors custom param decls.""" - command = cli.model_command(self.CustomDeclModel, self._noop_handler) - spec = self._option_spec(command, "custom_name") - tm.that(spec.param_decls, has="--custom-name") - tm.that(spec.param_decls, has="--projects") - - def test_model_command_renders_bool_field_as_toggle_flag(self) -> None: - """Verify that model command renders bool field as toggle flag.""" - command = cli.model_command(self.BoolToggleModel, self._noop_handler) - spec = self._option_spec(command, "debug") - tm.that(spec.param_decls, eq=["--debug/--no-debug"]) - - @pytest.mark.parametrize(("model_cls", "expected"), _ANNOTATION_CASES) - def test_model_command_normalizes_runtime_annotations( - self, model_cls: type[t.Cli.ModelLike], expected: t.Cli.RuntimeAnnotation + """The field alias is the option name the CLI accepts.""" + invocation, received = self._run(self.AliasOptionsModel, ["--project", "p1"]) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received[0].project_name, eq="p1") + + @pytest.mark.parametrize("option", ["--custom-name", "--projects"]) + def test_model_command_honors_custom_param_decls(self, option: str) -> None: + """Every custom declaration is accepted as the field's option.""" + invocation, received = self._run(self.CustomDeclModel, [option, "v"]) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received[0].custom_name, eq="v") + + @pytest.mark.parametrize( + ("option", "expected"), [("--debug", True), ("--no-debug", False)] + ) + def test_model_command_renders_bool_field_as_toggle_flag( + self, option: str, *, expected: bool ) -> None: - """Verify that model command normalizes runtime annotations.""" - command = cli.model_command(model_cls, self._noop_handler) - resolved = inspect.signature(command).parameters["value"].annotation - tm.that(resolved == expected, eq=True) + """A bool field is driven by an on/off toggle pair.""" + invocation, received = self._run(self.BoolToggleModel, [option]) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received[0].debug, eq=expected) + + @pytest.mark.parametrize(("args", "expected"), _INVOCATION_CASES) + def test_model_command_parses_values_for_each_annotation( + self, args: t.StrSequence, expected: t.Cli.ModelLike + ) -> None: + """Command-line values build the same model as direct construction.""" + invocation, received = self._run(type(expected), args) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received, eq=[expected]) - def test_model_command_marks_required_field_default_as_ellipsis(self) -> None: - """Verify that model command marks required field default as ellipsis.""" - command = cli.model_command(self.AliasOptionsModel, self._noop_handler) - spec = self._option_spec(command, "project_name") - tm.that(spec.default is ..., eq=True) + def test_model_command_rejects_missing_required_option(self) -> None: + """A required field without its option is a usage failure; no handler call.""" + invocation, received = self._run(self.AliasOptionsModel, []) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=False) + tm.that(received, empty=True) def test_field_default_prefers_settings_value_over_model_default(self) -> None: - """Verify that field default prefers settings value over model default.""" + """An omitted option takes the value of the supplied settings model.""" settings = self.OptionsDefaultsModel(name="override-name") - command = cli.model_command( - self.OptionsDefaultsModel, self._noop_handler, settings=settings + invocation, received = self._run( + self.OptionsDefaultsModel, [], settings=settings ) - spec = self._option_spec(command, "name") - tm.that(spec.default, eq="override-name") - - def test_field_default_normalizes_sequence_default_to_tuple(self) -> None: - """Verify that field default normalizes sequence default to tuple.""" - command = cli.model_command(self.OptionsDefaultsModel, self._noop_handler) - spec = self._option_spec(command, "generated") - tm.that(spec.default, eq=("gen", "value")) - - def test_field_default_preserves_normalizable_mapping(self) -> None: - """Verify that field default preserves normalizable mapping.""" - command = cli.model_command(self.OptionsDefaultsModel, self._noop_handler) - spec = self._option_spec(command, "valid_mapping") - tm.that(spec.default, eq=dict(c.Tests.OPTIONS_FIELD_DEFAULT_VALID_MAPPING)) - - def test_field_default_drops_non_normalizable_mapping_to_none(self) -> None: - """Verify that field default drops non normalizable mapping to none.""" - command = cli.model_command(self.OptionsDefaultsModel, self._noop_handler) - spec = self._option_spec(command, "invalid_mapping") - tm.that(spec.default, none=True) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received[0].name, eq="override-name") + + def test_field_defaults_reach_handler_when_options_omitted(self) -> None: + """Omitted options deliver the model defaults to the handler.""" + invocation, received = self._run(self.OptionsDefaultsModel, []) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + expected = self.OptionsDefaultsModel() + tm.that(received[0].name, eq=expected.name) + tm.that(tuple(received[0].tags), eq=tuple(expected.tags)) + tm.that(tuple(received[0].generated), eq=tuple(expected.generated)) # ---- end-to-end command invocation ---------------------------------- diff --git a/tests/unit/test_options_public_cov.py b/tests/unit/test_options_public_cov.py index 511b065bb..fccbcde60 100644 --- a/tests/unit/test_options_public_cov.py +++ b/tests/unit/test_options_public_cov.py @@ -29,14 +29,7 @@ class _OptionSettings(m.BaseModel): @pytest.mark.parametrize( ("annotation", "expected"), - [ - (str | None, str), - (str | int, str), - (dict[str, int], dict), - (frozenset[str], frozenset), - (set[str], set), - (int, int), - ], + [(str | None, str), (str | int, str), (dict[str, int], dict), (int, int)], ) def test_resolve_typer_annotation_maps_scalars_unions_and_collections( self, annotation: t.Cli.RuntimeAnnotation, expected: type @@ -44,9 +37,12 @@ def test_resolve_typer_annotation_maps_scalars_unions_and_collections( """Verify that resolve typer annotation maps scalars unions and collections.""" tm.that(u.Cli.resolve_typer_annotation(annotation) is expected, eq=True) - def test_resolve_typer_annotation_maps_string_sequence_to_list_of_str(self) -> None: - """Verify that resolve typer annotation maps string sequence to list of str.""" - tm.that(u.Cli.resolve_typer_annotation(t.StrSequence), eq=list[str]) + @pytest.mark.parametrize("annotation", [t.StrSequence, set[str], frozenset[str]]) + def test_resolve_typer_annotation_maps_string_collections_to_list_of_str( + self, annotation: t.Cli.RuntimeAnnotation + ) -> None: + """String sequences and sets become repeated options typed ``list[str]``.""" + tm.that(u.Cli.resolve_typer_annotation(annotation), eq=list[str]) @pytest.mark.parametrize( ("value", "expected"), diff --git a/tests/unit/test_output_cov.py b/tests/unit/test_output_cov.py index 88362020a..f8b9aca1f 100644 --- a/tests/unit/test_output_cov.py +++ b/tests/unit/test_output_cov.py @@ -7,7 +7,7 @@ import pytest from flext_tests import tm -from tests import c, u +from tests import c, m, u class TestsFlextCliOutputCov: @@ -239,16 +239,11 @@ def test_summary_emits_verb_and_counts( self, capsys: pytest.CaptureFixture[str] ) -> None: """Verify that summary emits verb and counts.""" - - class _FakeSummaryStats: - verb = "check" - total = 5 - success = 4 - failed = 1 - skipped = 0 - elapsed = 2.5 - - u.Cli.summary(_FakeSummaryStats()) + u.Cli.summary( + m.Tests.SummaryStats( + verb="check", total=5, success=4, failed=1, skipped=0, elapsed=2.5 + ) + ) out = capsys.readouterr().out tm.that(out, has="check") tm.that(out, has="Total: 5") @@ -257,16 +252,16 @@ def test_project_failure_lists_errors_and_truncation_notice( self, capsys: pytest.CaptureFixture[str] ) -> None: """Verify that project failure lists errors and truncation notice.""" - - class _FakeProjectFailureInfo: - project = "flext-cli" - elapsed = 3.0 - error_count = 2 - log_path = Path("log.txt") - max_show = 1 - errors = ("error line 1", "error line 2") - - u.Cli.project_failure(_FakeProjectFailureInfo()) + u.Cli.project_failure( + m.Tests.ProjectFailureInfo( + project="flext-cli", + elapsed=3.0, + error_count=2, + log_path=Path("log.txt"), + max_show=1, + errors=("error line 1", "error line 2"), + ) + ) out = capsys.readouterr().out tm.that(out, has="flext-cli") tm.that(out, has="error line 1") diff --git a/tests/unit/test_prompts_cov.py b/tests/unit/test_prompts_cov.py index 15e3ca04b..bdcd89436 100644 --- a/tests/unit/test_prompts_cov.py +++ b/tests/unit/test_prompts_cov.py @@ -3,7 +3,7 @@ Every assertion targets observable public behavior: the ``r[T]`` outcome of ``prompt`` / ``confirm`` / ``prompt_choice`` / ``prompt_password`` (success value or failure error), never internal logging side-effects or private state. Input -readers are injected at the genuine stdin boundary via the scripted test double. +enters through the public ``input_reader`` / ``password_reader`` ports. """ from __future__ import annotations @@ -24,19 +24,6 @@ class TestsFlextCliPromptsCov: """Behavior contract for the prompt service public surface.""" - def test_prompt_returns_typed_input_regardless_of_test_env( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] - ) -> None: - """Verify that prompt returns typed input regardless of test env.""" - prompts = ( - make_prompts(interactive_mode=True) - .use_input_values(["typed"]) - .override_test_env(enabled=False) - ) - result = prompts.prompt("message", default="default") - tm.ok(result) - tm.that(result.value, eq="typed") - @pytest.mark.parametrize( ("raw_input", "default", "expected"), [ @@ -48,20 +35,20 @@ def test_prompt_returns_typed_input_regardless_of_test_env( ) def test_prompt_normalizes_input_and_falls_back_to_default( self, - make_prompts: Callable[..., p.Tests.ScriptedPrompts], + make_prompts: Callable[..., p.Tests.Prompts], raw_input: str, default: str, expected: str, ) -> None: """Verify that prompt normalizes input and falls back to default.""" - prompts = make_prompts(interactive_mode=True).use_input_values([raw_input]) + prompts = make_prompts(inputs=[raw_input]) result = prompts.prompt("message", default=default) tm.ok(result) tm.that(result.value, eq=expected) @pytest.mark.parametrize("quiet", [True, False]) def test_prompt_returns_default_when_non_interactive( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts], *, quiet: bool + self, make_prompts: Callable[..., p.Tests.Prompts], *, quiet: bool ) -> None: """Verify that prompt returns default when non interactive.""" prompts = make_prompts(interactive_mode=False, quiet=quiet) @@ -70,12 +57,10 @@ def test_prompt_returns_default_when_non_interactive( tm.that(result.value, eq="fallback") def test_prompt_fails_when_input_reader_raises( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt fails when input reader raises.""" - prompts = make_prompts(interactive_mode=True).use_input_error( - ValueError("boom") - ) + prompts = make_prompts(error=ValueError("boom")) result = prompts.prompt("message", default="default") tm.fail(result, has="boom") @@ -92,34 +77,30 @@ def test_prompt_fails_when_input_reader_raises( ) def test_confirm_parses_yes_no_and_default( self, - make_prompts: Callable[..., p.Tests.ScriptedPrompts], + make_prompts: Callable[..., p.Tests.Prompts], answer: str, *, expected: bool, default: bool, ) -> None: """Verify that confirm parses yes no and default.""" - prompts = make_prompts(interactive_mode=True).use_input_values([answer]) + prompts = make_prompts(inputs=[answer]) result = prompts.confirm("message", default=default) tm.ok(result) tm.that(result.value, eq=expected) def test_confirm_retries_past_invalid_input_until_valid( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that confirm retries past invalid input until valid.""" - prompts = make_prompts(interactive_mode=True).use_input_values([ - "maybe", - "huh", - "y", - ]) + prompts = make_prompts(inputs=["maybe", "huh", "y"]) result = prompts.confirm("message", default=False) tm.ok(result) tm.that(result.value, eq=True) @pytest.mark.parametrize("default", [True, False]) def test_confirm_returns_default_when_non_interactive( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts], *, default: bool + self, make_prompts: Callable[..., p.Tests.Prompts], *, default: bool ) -> None: """Verify that confirm returns default when non interactive.""" prompts = make_prompts(interactive_mode=False) @@ -137,67 +118,73 @@ def test_confirm_returns_default_when_non_interactive( ) def test_confirm_fails_on_input_errors( self, - make_prompts: Callable[..., p.Tests.ScriptedPrompts], + make_prompts: Callable[..., p.Tests.Prompts], error: Exception, expected: str, ) -> None: """Verify that confirm fails on input errors.""" - prompts = make_prompts(interactive_mode=True).use_input_error(error) + prompts = make_prompts(error=error) result = prompts.confirm("message", default=False) tm.fail(result, has=expected) def test_prompt_choice_returns_default_when_present( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt choice returns default when present.""" - prompts = make_prompts(interactive_mode=True) + prompts = make_prompts() result = prompts.prompt_choice("Choose", choices=("a", "b"), default="a") tm.ok(result) tm.that(result.value, eq="a") def test_prompt_choice_fails_with_empty_choices( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt choice fails with empty choices.""" - prompts = make_prompts(interactive_mode=True) + prompts = make_prompts() result = prompts.prompt_choice("Choose", choices=(), default=None) tm.fail(result, has=c.Cli.ERR_NO_CHOICES) def test_prompt_choice_fails_when_default_not_in_choices( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt choice fails when default not in choices.""" - prompts = make_prompts(interactive_mode=True) + prompts = make_prompts() result = prompts.prompt_choice("Choose", choices=("a", "b"), default="z") tm.fail(result, has="z") def test_prompt_choice_fails_when_default_required( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt choice fails when default required.""" - prompts = make_prompts(interactive_mode=True) + prompts = make_prompts() result = prompts.prompt_choice("Choose", choices=("a", "b"), default=None) tm.fail(result) def test_prompt_password_returns_value_meeting_min_length( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, + make_prompts: Callable[..., p.Tests.Prompts], + scripted_password_pair: Callable[[], tuple[str, str]], ) -> None: """Verify that prompt password returns value meeting min length.""" - prompts = make_prompts(interactive_mode=True).use_password("s3cret") - result = prompts.prompt_password("Password:", min_length=4) + _, secret = scripted_password_pair() + prompts = make_prompts(password=secret) + result = prompts.prompt_password("Password:", min_length=len(secret)) tm.ok(result) - tm.that(result.value, eq="s3cret") + tm.that(result.value, eq=secret) def test_prompt_password_fails_when_too_short( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, + make_prompts: Callable[..., p.Tests.Prompts], + scripted_password_pair: Callable[[], tuple[str, str]], ) -> None: """Verify that prompt password fails when too short.""" - prompts = make_prompts(interactive_mode=True).use_password("ab") - result = prompts.prompt_password("Password:", min_length=5) + secret, _ = scripted_password_pair() + prompts = make_prompts(password=secret) + result = prompts.prompt_password("Password:", min_length=len(secret) + 1) tm.fail(result) def test_prompt_password_fails_when_non_interactive( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt password fails when non interactive.""" prompts = make_prompts(interactive_mode=False) @@ -205,12 +192,10 @@ def test_prompt_password_fails_when_non_interactive( tm.fail(result, has=c.Cli.ERR_INTERACTIVE_PASSWORD_DISABLED) def test_prompt_password_fails_when_reader_raises( - self, make_prompts: Callable[..., p.Tests.ScriptedPrompts] + self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt password fails when reader raises.""" - prompts = make_prompts(interactive_mode=True).use_password_error( - ValueError("no tty") - ) + prompts = make_prompts(error=ValueError("no tty")) result = prompts.prompt_password("Password:") tm.fail(result, has="no tty") diff --git a/tests/unit/test_protocols.py b/tests/unit/test_protocols.py deleted file mode 100644 index 313279669..000000000 --- a/tests/unit/test_protocols.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Behavioral tests for flext_cli.protocols.FlextCliProtocols. - -The public contract of ``FlextCliProtocols`` is its ``@runtime_checkable`` -structural protocols: at runtime ``isinstance(obj, Protocol)`` must accept an -object that structurally conforms and reject one that does not. These tests -exercise that observable contract through the canonical ``p`` facade only -- -never protocol internals. - -Modules tested: flext_cli.protocols.FlextCliProtocols - -Copyright (c) 2025 FLEXT Team. All rights reserved. -SPDX-License-Identifier: MIT - -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from flext_tests import tm - -from flext_cli import t -from tests import p - - -class _ConformingSummary: - """Object structurally conforming to ``SummaryStats``.""" - - verb: str = "build" - total: int = 3 - success: int = 2 - failed: int = 1 - skipped: int = 0 - elapsed: float = 1.5 - - -class _PartialSummary: - """Object missing most ``SummaryStats`` attributes.""" - - verb: str = "build" - - -class _ConformingYamlModule: - """Object structurally conforming to ``YamlModule``.""" - - def dump(self, data: object, *, default_flow_style: bool = True) -> str: - del data, default_flow_style - return "" - - -class _ConformingContext: - """Object structurally conforming to ``PipelineStageContext``.""" - - @property - def repository_root(self) -> Path: - return Path() - - @property - def shared(self) -> t.MutableJsonMapping: - return {} - - @property - def settings(self) -> t.JsonMapping: - return {} - - -class TestsFlextCliProtocols: - """Behavioral contract of the FlextCliProtocols runtime protocols.""" - - def test_data_protocol_accepts_structurally_conforming_object(self) -> None: - """A fully-populated object satisfies the data-attribute protocol.""" - tm.that(_ConformingSummary(), is_=p.Cli.SummaryStats) - - def test_data_protocol_rejects_object_missing_required_attributes(self) -> None: - """An object missing declared attributes fails the protocol check.""" - tm.that(isinstance(_PartialSummary(), p.Cli.SummaryStats), eq=False) - - def test_method_protocol_accepts_object_exposing_method(self) -> None: - """An object exposing ``dump`` satisfies ``YamlModule``.""" - tm.that(_ConformingYamlModule(), is_=p.Cli.YamlModule) - - def test_method_protocol_rejects_object_without_method(self) -> None: - """An object lacking ``dump`` is rejected by ``YamlModule``.""" - tm.that(isinstance(object(), p.Cli.YamlModule), eq=False) - - def test_callable_protocol_accepts_plain_callable(self) -> None: - """Any single-arg callable conforms to ``JsonValueProcessor``.""" - - def identity(value: t.JsonValue) -> t.JsonValue: - return value - - tm.that(identity, is_=p.Cli.JsonValueProcessor) - - def test_property_protocol_accepts_object_exposing_properties(self) -> None: - """An object exposing all context properties satisfies the protocol.""" - tm.that(_ConformingContext(), is_=p.Cli.PipelineStageContext) - - def test_property_protocol_rejects_object_missing_a_property(self) -> None: - """Missing a single required property fails ``PipelineStageContext``.""" - - class _MissingSettings: - @property - def repository_root(self) -> object: ... - - @property - def shared(self) -> object: ... - - tm.that(isinstance(_MissingSettings(), p.Cli.PipelineStageContext), eq=False) - - @pytest.mark.parametrize( - "protocol_name", - [ - "CommandEntry", - "SummaryStats", - "ProjectFailureInfo", - "JsonValueProcessor", - "YamlModule", - "PipelineStageContext", - "PipelineService", - ], - ) - def test_cli_protocols_are_runtime_checkable(self, protocol_name: str) -> None: - """Each published protocol supports runtime ``isinstance`` without error.""" - protocol = getattr(p.Cli, protocol_name) - tm.that(protocol, is_=type) - # A runtime-checkable protocol answers isinstance instead of raising. - tm.that({True, False}, has=isinstance(object(), protocol)) - - @pytest.mark.parametrize( - "protocol_name", - [ - "CommandEntry", - "SummaryStats", - "YamlModule", - "JsonValueProcessor", - "PipelineService", - ], - ) - def test_cli_protocol_is_stable_singleton_under_namespace( - self, protocol_name: str - ) -> None: - """Each CLI protocol resolves to one shared object under ``p.Cli``.""" - tm.that(getattr(p.Cli, protocol_name) is getattr(p.Cli, protocol_name), eq=True) - - def test_result_protocol_inherited_from_core_facade(self) -> None: - """The CLI facade re-exposes the core ``Result`` protocol contract.""" - tm.that(p.Result, none=False) - tm.that(p.Result, is_=type) diff --git a/tests/unit/test_runtime_streamed_process.py b/tests/unit/test_runtime_streamed_process.py index 2b2613166..da620f1c3 100644 --- a/tests/unit/test_runtime_streamed_process.py +++ b/tests/unit/test_runtime_streamed_process.py @@ -12,7 +12,7 @@ import pytest from flext_tests import tm -from tests import c, m, p, u +from tests import c, m, u if TYPE_CHECKING: from pathlib import Path @@ -29,10 +29,9 @@ class TestsFlextCliRuntimeStreamedProcess: """Prove streaming, deadlines, exact exits, and descendant cleanup.""" @staticmethod - def _input_pump_is_alive() -> bool: - return any( - thread.name == "flext-cli-process-input" for thread in threading.enumerate() - ) + def _live_threads() -> frozenset[threading.Thread]: + """Snapshot live threads so a run can be proven to leak none.""" + return frozenset(threading.enumerate()) def test_combined_output_is_byte_exact_and_live( self, tmp_path: Path, capfd: pytest.CaptureFixture[str] @@ -129,6 +128,7 @@ def test_input_larger_than_pipe_capacity_is_streamed_without_deadlock( expected_bytes = ( payload.encode("utf-8") if isinstance(payload, str) else payload ) + threads_before = self._live_threads() result = u.Cli().run_to_file( [ @@ -144,11 +144,12 @@ def test_input_larger_than_pipe_capacity_is_streamed_without_deadlock( tm.ok(result) tm.that(result.value.raw_return_code, eq=0) tm.that(output_file.read_bytes(), eq=expected_bytes) - tm.that(self._input_pump_is_alive(), eq=False) + tm.that(self._live_threads() - threads_before, empty=True) def test_zero_length_input_publishes_eof(self, tmp_path: Path) -> None: """An explicitly empty payload still owns a pipe and closes its writer.""" output_file = tmp_path / "empty-stdin.log" + threads_before = self._live_threads() result = u.Cli().run_to_file( [ sys.executable, @@ -162,10 +163,11 @@ def test_zero_length_input_publishes_eof(self, tmp_path: Path) -> None: tm.ok(result) tm.that(result.value.raw_return_code, eq=0) tm.that(output_file.read_text(encoding="utf-8"), eq="0") - tm.that(self._input_pump_is_alive(), eq=False) + tm.that(self._live_threads() - threads_before, empty=True) def test_child_early_exit_preserves_its_exact_status(self, tmp_path: Path) -> None: """A secondary broken input pipe cannot replace the child's real exit.""" + threads_before = self._live_threads() result = u.Cli().run_to_file( [sys.executable, "-c", "raise SystemExit(23)"], tmp_path / "early-exit.log", @@ -174,12 +176,13 @@ def test_child_early_exit_preserves_its_exact_status(self, tmp_path: Path) -> No tm.ok(result) tm.that(result.value.raw_return_code, eq=23) - tm.that(self._input_pump_is_alive(), eq=False) + tm.that(self._live_threads() - threads_before, empty=True) def test_nonreading_child_timeout_unblocks_the_input_writer( self, tmp_path: Path ) -> None: """Killing the child removes the last reader and releases a full writer.""" + threads_before = self._live_threads() result = u.Cli().run_to_file( [sys.executable, "-c", "import time;time.sleep(30)"], tmp_path / "timeout-input.log", @@ -189,13 +192,7 @@ def test_nonreading_child_timeout_unblocks_the_input_writer( tm.ok(result) tm.that(result.value.timed_out, eq=True) - tm.that(self._input_pump_is_alive(), eq=False) - - def test_deadline_model_satisfies_public_protocol(self) -> None: - """Expose one typed model through the structural public protocol.""" - deadline = _deadline(seconds=2.0, grace=0.5) - - tm.that(deadline, is_=p.Cli.ProcessDeadline) + tm.that(self._live_threads() - threads_before, empty=True) def test_deadline_timeout_reports_causal_outcome(self, tmp_path: Path) -> None: """Deadline expiry reports its causal outcome instead of failing.""" diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 84023502f..44b1316f6 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -12,7 +12,6 @@ from __future__ import annotations -import pytest from flext_tests import tm import flext_cli @@ -35,11 +34,6 @@ def test_package_version_matches_semver_contract(self) -> None: """``flext_cli.__version__`` honours the published semver pattern.""" tm.that(c.PATTERN_SEMVER_RE.match(flext_cli.__version__), none=False) - def test_package_version_within_length_bounds(self) -> None: - """``flext_cli.__version__`` stays within sane display bounds.""" - tm.that(len(flext_cli.__version__), gte=5) - tm.that(len(flext_cli.__version__), lte=50) - def test_package_version_info_is_tuple_of_at_least_three_parts(self) -> None: """``flext_cli.__version_info__`` is a tuple carrying major/minor/patch.""" info = flext_cli.__version_info__ @@ -72,16 +66,6 @@ def test_cli_version_constant_matches_semver_contract(self) -> None: """The runtime ``c.Cli.CLI_VERSION`` constant is semver-compliant.""" tm.that(c.Cli.CLI_VERSION, is_=str) tm.that(c.PATTERN_SEMVER_RE.match(c.Cli.CLI_VERSION), none=False) - tm.that(len(c.Cli.CLI_VERSION), gte=5) - tm.that(len(c.Cli.CLI_VERSION), lte=50) - - def test_cli_version_constant_exposes_major_minor_patch(self) -> None: - """``c.Cli.CLI_VERSION`` yields extractable major/minor/patch parts.""" - parts = c.Cli.CLI_VERSION.split(".") - tm.that(len(parts), gte=3) - for part in parts[:3]: - tm.that(part.isdigit(), eq=True) - tm.that(int(part), gte=0) def test_execute_publishes_cli_version_in_runtime_payload(self) -> None: """``cli.execute()`` succeeds and reports the CLI version string.""" @@ -91,32 +75,3 @@ def test_execute_publishes_cli_version_in_runtime_payload(self) -> None: version = payload.version tm.that(version, is_=str) tm.that(version, eq=c.Cli.CLI_VERSION) - - def test_execute_reports_version_deterministically(self) -> None: - """Repeated ``cli.execute()`` calls report an identical version.""" - first = cli.execute() - second = cli.execute() - tm.ok(first) - tm.ok(second) - tm.that(first.value.version, eq=second.value.version) - - @pytest.mark.parametrize( - ("candidate", "is_valid"), - [ - ("0.0.0", True), - ("1.2.3", True), - ("10.20.30", True), - ("1.0.0-dev0", True), - ("1.0.0+build.5", True), - ("", False), - ("1", False), - ("1.2", False), - ("v1.2.3", False), - ("1.2.x", False), - ("not-a-version", False), - ], - ) - def test_semver_pattern_contract(self, candidate: str, *, is_valid: bool) -> None: - """The published semver pattern accepts valid and rejects invalid strings.""" - matched = c.PATTERN_SEMVER_RE.match(candidate) is not None - tm.that(matched, eq=is_valid)