From 2b55974ee9a2684e5664fc0ab77f3079b6fd4d39 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 05:28:43 -0400 Subject: [PATCH 1/9] plan(beehave-v2): author contract surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source .pyi stubs (7): __init__, step, gherkin (8 parse-model shapes + parse_feature, Q2 collapse — no models.pyi), generate, check, status, cli. Test contract (9 pairs .pyi/.py + 2 conftests): - tests/integration/: step_cm, title_derivation, idempotency, roundtrip, strategy_inference, parsing (55 falsifiable claims, @pytest.mark.pending) - tests/e2e/: check, generate, status (pytester-driven CLI surface) docs/glossary.md: 3 contexts, 23 terms. Build-entry setup (v1 deletion, mypy/stubtest/taskipy wiring, py.typed) follows on feature/beehave-v2. --- beehave/__init__.pyi | 10 + beehave/check.pyi | 8 + beehave/cli.pyi | 6 + beehave/generate.pyi | 9 + beehave/gherkin.pyi | 49 +++++ beehave/status.pyi | 8 + beehave/step.pyi | 18 ++ docs/glossary.md | 126 +++++++++++ tests/e2e/check_test.py | 199 ++++++++++++++++++ tests/e2e/check_test.pyi | 26 +++ tests/e2e/conftest.py | 22 ++ tests/e2e/generate_test.py | 185 ++++++++++++++++ tests/e2e/generate_test.pyi | 49 +++++ tests/e2e/status_test.py | 56 +++++ tests/e2e/status_test.pyi | 22 ++ tests/integration/conftest.py | 20 ++ tests/integration/idempotency_test.py | 87 ++++++++ tests/integration/idempotency_test.pyi | 21 ++ tests/integration/parsing_test.py | 41 ++++ tests/integration/parsing_test.pyi | 19 ++ tests/integration/roundtrip_test.py | 73 +++++++ tests/integration/roundtrip_test.pyi | 23 ++ tests/integration/step_cm_test.py | 86 ++++++++ tests/integration/step_cm_test.pyi | 23 ++ tests/integration/strategy_inference_test.py | 130 ++++++++++++ tests/integration/strategy_inference_test.pyi | 27 +++ tests/integration/title_derivation_test.py | 74 +++++++ tests/integration/title_derivation_test.pyi | 27 +++ 28 files changed, 1444 insertions(+) create mode 100644 beehave/__init__.pyi create mode 100644 beehave/check.pyi create mode 100644 beehave/cli.pyi create mode 100644 beehave/generate.pyi create mode 100644 beehave/gherkin.pyi create mode 100644 beehave/status.pyi create mode 100644 beehave/step.pyi create mode 100644 docs/glossary.md create mode 100644 tests/e2e/check_test.py create mode 100644 tests/e2e/check_test.pyi create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/generate_test.py create mode 100644 tests/e2e/generate_test.pyi create mode 100644 tests/e2e/status_test.py create mode 100644 tests/e2e/status_test.pyi create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/idempotency_test.py create mode 100644 tests/integration/idempotency_test.pyi create mode 100644 tests/integration/parsing_test.py create mode 100644 tests/integration/parsing_test.pyi create mode 100644 tests/integration/roundtrip_test.py create mode 100644 tests/integration/roundtrip_test.pyi create mode 100644 tests/integration/step_cm_test.py create mode 100644 tests/integration/step_cm_test.pyi create mode 100644 tests/integration/strategy_inference_test.py create mode 100644 tests/integration/strategy_inference_test.pyi create mode 100644 tests/integration/title_derivation_test.py create mode 100644 tests/integration/title_derivation_test.pyi diff --git a/beehave/__init__.pyi b/beehave/__init__.pyi new file mode 100644 index 0000000..28ab9b1 --- /dev/null +++ b/beehave/__init__.pyi @@ -0,0 +1,10 @@ +# Public package surface for beehave v2. +# +# - `step` is the executable `with`-block CM (re-exported; lives in +# `beehave.step`). +# - `__version__` is the single source of truth (interview L1 Constraint 3; +# the `== "2.0.0"` assertion is deferred to deliver — only the +# declaration lives here). +from beehave.step import step as step + +__version__: str diff --git a/beehave/check.pyi b/beehave/check.pyi new file mode 100644 index 0000000..9d758ea --- /dev/null +++ b/beehave/check.pyi @@ -0,0 +1,8 @@ +# Structural binding check (L2 *Validation (`check`)*): the +# `with step(...)` blocks in `test_py_text` are matched one-to-one against +# the steps parsed from `feature_text` on the triple +# (keyword-case-insensitively, text, placeholder-name-set). Returns True if +# every block matches its step; False on any count, keyword, text, or +# placeholder-name-set mismatch. Does NOT inspect the body for literals or +# placeholder AST nodes (v2 drops that layer entirely — Q5). +def check(feature_text: str, test_py_text: str) -> bool: ... diff --git a/beehave/cli.pyi b/beehave/cli.pyi new file mode 100644 index 0000000..92c4c1e --- /dev/null +++ b/beehave/cli.pyi @@ -0,0 +1,6 @@ +from collections.abc import Sequence + +# CLI entry: `beehave generate|check|status`. Returns the process exit code +# (0 success; 2 missing features dir on `status`; non-zero on `check` +# structural-binding failure). `argv` defaults to `sys.argv[1:]` when None. +def main(argv: Sequence[str] | None = None) -> int: ... diff --git a/beehave/generate.pyi b/beehave/generate.pyi new file mode 100644 index 0000000..1400f92 --- /dev/null +++ b/beehave/generate.pyi @@ -0,0 +1,9 @@ +from pathlib import Path + +# Emits `_default_test.py{i,}` plus one +# `__test.py{i,}` per Rule into `/tests/`, +# reading `/docs/features/*.feature`. Always emits `.pyi`; emits `.py` +# skeleton only when absent (idempotent — never clobbers consumer bodies). +# Background steps (Feature-level and Rule-level) are prepended to the +# relevant scenarios' `with step(...)` block lists in the emitted `.py`. +def generate(root: Path) -> None: ... diff --git a/beehave/gherkin.pyi b/beehave/gherkin.pyi new file mode 100644 index 0000000..fc90bae --- /dev/null +++ b/beehave/gherkin.pyi @@ -0,0 +1,49 @@ +# The parser + its in-memory parse model (collapsed here per Q2-resolution: +# no separate `models` shared kernel — the shapes are returned by exactly +# one public entry point, `parse_feature`, and consumed by intra-package +# collaborators in the same bounded context; tests never import them as a +# `beehave.models` module). Field shapes are binding per data-model.md §2. + +class Placeholder: + name: str + +class DataTable: + headers: list[str] | None + rows: list[list[str]] + +class Step: + keyword: str + text: str + placeholders: list[Placeholder] + docstring: str | None + data_table: DataTable | None + +class Examples: + headers: list[str] + rows: list[dict[str, str]] + +class Background: + steps: list[Step] + +class Scenario: + title: str + slug: str + function_name: str + tags: list[str] + keyword: str + steps: list[Step] + examples: Examples | None + +class Rule: + name: str + tags: list[str] + background: Background | None + children: list[Scenario] + +class Feature: + name: str + tags: list[str] + background: Background | None + children: list[Rule | Scenario] + +def parse_feature(source: str) -> Feature: ... diff --git a/beehave/status.pyi b/beehave/status.pyi new file mode 100644 index 0000000..73eed86 --- /dev/null +++ b/beehave/status.pyi @@ -0,0 +1,8 @@ +from pathlib import Path + +# Minimal `status` (journal Q3): prints `.feature` count under +# `/docs/features/` and `*_test.pyi` count under `/tests/` to +# stdout; returns 0 when the features directory exists, 2 when it is +# missing (filesystem error). v1's rich stage taxonomy / `--json` / tree +# output / unmapped-directory reporting are all dropped. +def status(root: Path) -> int: ... diff --git a/beehave/step.pyi b/beehave/step.pyi new file mode 100644 index 0000000..cf3d66f --- /dev/null +++ b/beehave/step.pyi @@ -0,0 +1,18 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +# The v2 runtime core: `step(keyword, text, /, **placeholders)` is the +# executable `with` block that replaces v1's step-definition registry. +# `keyword` is data (covers all Gherkin keywords incl. localized); `assert` +# inside a `Then` block propagates; on exception the CM appends +# `f"{keyword} {text}"` via `add_note` so `__notes__ == [" "]`; +# no note on clean exit. `keyword`/`text` are positional-only; placeholder +# values are consumer-supplied scalars (strategy-inferred int/float/bool/str +# in emitted tests), so the kwarg type is `object`. +@contextmanager +def step( + keyword: str, + text: str, + /, + **placeholders: object, +) -> Iterator[None]: ... diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..217656a --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,126 @@ +# Glossary: beehave + +> The ubiquitous language for this project — terms shared across conversation, +> code, and documentation (Evans, 2003). Curated from the interview for the +> IMPORTANT domain concepts, not every code symbol. Grouped by bounded context, +> where each term has one meaning. The tests are the source of truth for +> behaviour; this glossary is the source of truth for names. Extend or revise +> entries as understanding shifts. + +## Context: Design-time (the CLI tool) + +### `.feature` +A source artifact that holds Gherkin-syntax feature definitions and is the single source of truth for generation. +*Aliases: feature file · Source: interview 2026-07-18* + +### Feature +A Gherkin structural element that is the top-level container in a `.feature` file. +*Aliases: none · Source: interview 2026-07-18* + +### Rule +A Gherkin structural element that groups scenarios within a Feature and is the unit of per-rule test-file generation. +*Aliases: none · Source: interview 2026-07-18* + +### Background +A Gherkin structural element whose steps are merged into every scenario's step list and may not contain placeholders. +*Aliases: none · Source: interview 2026-07-18* + +### Scenario +A Gherkin structural element that maps one-to-one to a generated `test_` function whose name is its identity. +*Aliases: none · Source: interview 2026-07-18* + +### Examples +A tabular value source whose columns drive Hypothesis `@given` strategy inference and whose rows drive `@example`. +*Aliases: Examples table · Source: interview 2026-07-18* + +### step +A Gherkin structural element that is the unit matched one-to-one (`block[i]`↔`step[i]`) against `with step(...)` blocks in the test body. +*Aliases: Gherkin step · Source: interview 2026-07-18* + +### placeholder +A parameterisation token of the form `` that binds Examples-table columns to step-text positions and whose name-set is one of the three structural-binding fields. +*Aliases: none · Source: interview 2026-07-18* + +### title +An identifier that derives the `test_` function name and is constrained by uniqueness (case-insensitive), a 2–6 word-count bound, and a Unicode-letters/digits/spaces charset. +*Aliases: none · Source: interview 2026-07-18* + +### slug +A normalised identifier that is the lowercased, whitespace-collapsed form of a title and becomes the suffix of the `test_` function name. +*Aliases: none · Source: interview 2026-07-18* + +### Full Gherkin +A grammar-coverage claim meaning v2 parses everything `gherkin-official` emits, including `@tags`, docstrings, and data-tables. +*Aliases: none · Source: interview 2026-07-18* + +### noise loophole +A spec-value-fidelity failure in which a placeholder or literal "appears" in a test body as an AST node while testing nothing about the step's actual behaviour — the v1 incident that motivates v2. +*Aliases: none · Source: interview 2026-07-18 (CIT)* + +### `generate` +A CLI command that emits the `.pyi` typed-stub contract always and the `*_test.py` skeleton only if the `.py` is absent (idempotent — never clobbers existing bodies). +*Aliases: none · Source: interview 2026-07-18* + +### `check` +A CLI command that validates the `with step(...)` structural binding against the `.feature` and enforces title rules. +*Aliases: none · Source: interview 2026-07-18* + +### `status` +A CLI command that reports generation/check progress (detailed behaviour deferred to plan). +*Aliases: none · Source: interview 2026-07-18* + +### parse model +The in-memory typed shapes (`Feature` / `Rule` / `Scenario` / `Step` / `Placeholder` / `Examples` / `Background` / `DataTable`) carried by `beehave/models.py` as the shared kernel between `gherkin.py`, `generate.py`, and `check.py`. Distinguished from *persistence model* — v2 has none (the parse model is the binding data contract). +*Aliases: none · Source: data-model 2026-07-18* + +### shared kernel +A bounded-context pattern (Evans, 2003) applied to `beehave/models.py`: a small explicitly-shared vocabulary consumed by `gherkin.py`, `generate.py`, and `check.py`. The seam is justified by the three-consumer access pattern; whether it stays its own module or collapses into `gherkin.py` is an internal source-structure choice, not a data-model concern. +*Aliases: none · Source: interview 2026-07-18 (L3) + data-model 2026-07-18* + +### structural binding +The `block[i]`↔`step[i]` match on exactly `(keyword, text, placeholder-name-set)` — with `keyword` compared case-insensitively — that `beehave check` enforces by walking the test body's `with step(...)` blocks in source order. Body fidelity is deferred to the review gate; this is the structural-only check that replaces v1's appearance enforcement (the noise loophole). +*Aliases: none · Source: interview 2026-07-18 (L1 Success) + plan design decision 2026-07-18 (keyword case)* + +### default group +The emission group for scenarios NOT under a Rule; emitted to `_default_test.py{i,}` alongside one `__test.py{i,}` per Rule. +*Aliases: none · Source: plan design decision 2026-07-18* + +### skeleton +The `*_test.py` body emitted by `beehave generate` ONLY IF the `.py` is absent; a scaffold of `with step(...)` blocks the consumer fills. Re-running `generate` never clobbers an existing skeleton (idempotent) — only the `.pyi` is rewritten. +*Aliases: test skeleton · Source: interview 2026-07-18 (Constraint 1)* + +## Context: Runtime (beehave-the-import) + +### `step` +A context manager imported `from beehave import step` that wraps a step's executable test code as `with step(keyword, text, **placeholders)` and attributes failures to its step via `add_note`. +*Aliases: step context manager · Source: interview 2026-07-18* + +### keyword +A positional argument to the `step` context manager that is a STRING (data, not a method name) so it covers all Gherkin step keywords including localized variants without reserved-word clashes. +*Aliases: none · Source: interview 2026-07-18* + +### `Then`-asserts +A runtime contract that the `Then` step block is where the outcome assertion executes — the step block RUNS code, it does not merely declare it. +*Aliases: none · Source: interview 2026-07-18* + +### `add_note` +A pytest mechanism that the `step` context manager uses to attribute a failure to its specific step by name. +*Aliases: none · Source: interview 2026-07-18* + +## Context: Typing contract + +### `.pyi` +A type-stub file that is the typed contract surface `generate` always emits and that consumer type-checkers read in preference to the `.py`. +*Aliases: stub · Source: interview 2026-07-18* + +### `py.typed` +A PEP 561 package-marker file (empty) that signals to type-checkers that the package ships typed stubs, on which the consumer-side mypy gate depends. +*Aliases: none · Source: interview 2026-07-18* + +### drift +A contract violation in which a generated `*_test.py` body diverges from its `.pyi`. +*Aliases: stub drift · Source: interview 2026-07-18* + +### `mypy.stubtest` +A tool that is the SOLE `.py`↔`.pyi` drift detector in v2's gate (pyright/mypy read only the `.pyi`). +*Aliases: stubtest · Source: interview 2026-07-18* diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py new file mode 100644 index 0000000..97e6514 --- /dev/null +++ b/tests/e2e/check_test.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +HIVE_ACTIVITY_FEATURE = "hive_activity.feature" +COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" + + +def copy_feature_into_pytester(pytester, basename: str) -> str: + src = Path("docs") / "features" / basename + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + return str(dst) + + +def write_feature_text(pytester, basename: str, text: str) -> str: + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(text) + return str(dst) + + +def write_test_py(pytester, stem: str, body: str) -> str: + dst = pytester.path / "tests" / f"{stem}_test.py" + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(body) + return str(dst) + + +def run_beehave_check(pytester, *args: str) -> int: + return pytester.run("beehave", "check", *args).ret + + +@pytest.mark.pending +def test_passes_when_blocks_match_steps(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + assert run_beehave_check(pytester) == 0 + + +@pytest.mark.pending +def test_fails_when_block_count_differs_from_step_count(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + short_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", short_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + mismatched_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ' with step("Given", "second step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", mismatched_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_fails_when_step_text_mismatches(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + mismatched_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ' with step("When", "different text"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", mismatched_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario Outline: minimal scenario\n" + "Given a value of \n" + "When anything\n" + "\n" + "Examples:\n" + " | amount |\n" + " | 1 |\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + mismatched_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario(amount):\n" + ' with step("Given", "a value of ", renamed=1):\n' + " pass\n" + ' with step("When", "anything"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", mismatched_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_passes_when_keyword_case_differs(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + case_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("given", "first step"):\n' + " pass\n" + ' with step("when", "second step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", case_body) + assert run_beehave_check(pytester) == 0 + + +@pytest.mark.pending +def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + body_with_content = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " x = 1 + 1\n" + " y = x * 2\n" + ' with step("When", "second step"):\n' + " z = y + 1\n" + ) + write_test_py(pytester, "minimal_default", body_with_content) + assert run_beehave_check(pytester) == 0 + + +@pytest.mark.pending +def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + body_without_literals = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ' with step("When", "second step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", body_without_literals) + assert run_beehave_check(pytester) == 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi new file mode 100644 index 0000000..b6f1b01 --- /dev/null +++ b/tests/e2e/check_test.pyi @@ -0,0 +1,26 @@ +# E2E contract for the `beehave check` CLI command. +# +# Drives the CLI through the pytester subprocess. Asserts the structural-binding +# gate (the v2 CIT-rooted contract): the test body's `with step(...)` blocks in +# source order match the parsed `.feature` steps `block[i] <-> step[i]` on +# exactly three fields - `(keyword, text, placeholder-name-set)` - and NOTHING +# else. Body content is deferred to the review gate. Keyword matching is +# case-insensitive. Literal/placeholder AST appearance is NOT inspected +# (v1 noise loophole closed). + +# Fixture feature basenames available under docs/features/. +HIVE_ACTIVITY_FEATURE: str +COMB_CONSTRUCTION_FEATURE: str + +def copy_feature_into_pytester(pytester, basename: str) -> str: ... +def write_feature_text(pytester, basename: str, text: str) -> str: ... +def write_test_py(pytester, stem: str, body: str) -> str: ... +def run_beehave_check(pytester, *args: str) -> int: ... +def test_passes_when_blocks_match_steps(pytester) -> None: ... +def test_fails_when_block_count_differs_from_step_count(pytester) -> None: ... +def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: ... +def test_fails_when_step_text_mismatches(pytester) -> None: ... +def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: ... +def test_passes_when_keyword_case_differs(pytester) -> None: ... +def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: ... +def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ... diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..f05be32 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import pytest + +pytest_plugins = ["pytester"] + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "pending: v2 source not yet built; skipped until the build subflow removes the marker", + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + skip = pytest.mark.skip(reason="pending v2 source") + for item in items: + if "pending" in item.keywords: + item.add_marker(skip) diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py new file mode 100644 index 0000000..e86075b --- /dev/null +++ b/tests/e2e/generate_test.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +HIVE_ACTIVITY_FEATURE = "hive_activity.feature" +COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" +TITLE_VALIDATION_FEATURE = "title_validation.feature" +STATUS_COMMAND_FEATURE = "status_command.feature" +CASE_INSENSITIVE_MATCHING_FEATURE = "case_insensitive_matching.feature" + +DEFAULT_GROUP_SUFFIX = "default" +EMISSION_DIR = "tests" + + +def copy_feature_into_pytester(pytester, basename: str) -> str: + src = Path("docs") / "features" / basename + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + return str(dst) + + +def write_feature_text(pytester, basename: str, text: str) -> str: + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(text) + return str(dst) + + +def run_beehave_generate(pytester, *args: str) -> int: + return pytester.run("beehave", "generate", *args).ret + + +def read_emitted_pyi(pytester, stem: str) -> str: + path = pytester.path / EMISSION_DIR / f"{stem}_test.pyi" + if not path.exists(): + return "" + return path.read_text() + + +def read_emitted_py(pytester, stem: str) -> str: + path = pytester.path / EMISSION_DIR / f"{stem}_test.py" + if not path.exists(): + return "" + return path.read_text() + + +def list_emitted_stems(pytester) -> list[str]: + emission = pytester.path / EMISSION_DIR + if not emission.exists(): + return [] + stems: list[str] = [] + for path in emission.glob("*_test.pyi"): + name = path.name + if name.endswith("_test.pyi"): + stems.append(name[: -len("_test.pyi")]) + return sorted(stems) + + +@pytest.mark.pending +def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + stems = list_emitted_stems(pytester) + assert "hive_activity_default" in stems + assert "hive_activity_hive_defense" in stems + assert "hive_activity_hive_foraging" in stems + + +@pytest.mark.pending +def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + exit_code = run_beehave_generate(pytester) + assert exit_code == 0 + stems = list_emitted_stems(pytester) + assert len(stems) >= 3 + for stem in stems: + assert read_emitted_pyi(pytester, stem) != "" + + +@pytest.mark.pending +def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + first_emission = read_emitted_py(pytester, "hive_activity_default") + pytester.run("beehave", "generate") + second_emission = read_emitted_py(pytester, "hive_activity_default") + assert first_emission == second_emission + + +@pytest.mark.pending +def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "hive_activity_hive_defense") + assert "def test_guard_bee_inspects_visitor" in pyi + + +@pytest.mark.pending +def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) -> None: + feature_text = ( + "Feature: Whitespace\n" + "Scenario: MixedCase Title With Spaces\n" + "Given anything\n" + ) + write_feature_text(pytester, "whitespace.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "whitespace_default") + assert "def test_mixedcase_title_with_spaces" in pyi + + +@pytest.mark.pending +def test_feature_background_steps_appear_in_every_emitted_scenario(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + default_py = read_emitted_py(pytester, "hive_activity_default") + defense_py = read_emitted_py(pytester, "hive_activity_hive_defense") + foraging_py = read_emitted_py(pytester, "hive_activity_hive_foraging") + background_text = "the hive is active" + assert background_text in default_py + assert background_text in defense_py + assert background_text in foraging_py + + +@pytest.mark.pending +def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + defense_py = read_emitted_py(pytester, "hive_activity_hive_defense") + foraging_py = read_emitted_py(pytester, "hive_activity_hive_foraging") + default_py = read_emitted_py(pytester, "hive_activity_default") + rule_background_text = "entrance has 2 guards" + assert rule_background_text in defense_py + assert rule_background_text not in foraging_py + assert rule_background_text not in default_py + + +@pytest.mark.pending +def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: + feature_text = ( + "@unique_tag_marker\n" + "Feature: Tagged\n" + "Scenario: tagged scenario\n" + "Given anything\n" + ) + write_feature_text(pytester, "tagged.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "tagged_default") + py_text = read_emitted_py(pytester, "tagged_default") + assert "unique_tag_marker" not in pyi + assert "unique_tag_marker" not in py_text + + +@pytest.mark.pending +def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: + feature_text = ( + "Feature: Docstring\n" + "Scenario: scenario with docstring\n" + "Given anything\n" + '"""\n' + "unique docstring marker text\n" + '"""\n' + ) + write_feature_text(pytester, "docstring.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "docstring_default") + assert "unique docstring marker text" not in pyi + + +@pytest.mark.pending +def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: + feature_text = ( + "Feature: DataTable\n" + "Scenario: scenario with data table\n" + "Given anything\n" + " | unique_col | value |\n" + " | marker | 1 |\n" + ) + write_feature_text(pytester, "datatable.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "datatable_default") + assert "unique_col" not in pyi diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi new file mode 100644 index 0000000..5052946 --- /dev/null +++ b/tests/e2e/generate_test.pyi @@ -0,0 +1,49 @@ +# E2E contract for the `beehave generate` CLI command. +# +# Drives the CLI through the pytester subprocess against `docs/features/*.feature` +# and asserts the observable emission surface: +# - `_default_test.pyi` plus one `__test.pyi` per Rule; +# - the `.pyi` is always emitted (Constraint 1); the `.py` skeleton is emitted +# only when absent (idempotency lives at the integration layer); +# - Scenario title -> `test_` function name (slug = lowercased title, +# whitespace runs collapsed to a single underscore); +# - Feature and Rule Background steps are prepended to every relevant +# scenario's `with step(...)` block list in the emitted `.py`; +# - tags, step-docstrings, and step-data-tables are parsed (Full Gherkin) but +# do NOT surface in the emitted `.pyi` signature or `with step(...)` block +# shape (minimal-surface preference). + +# Fixture feature basenames available under docs/features/ (the v2 E2E inputs). +HIVE_ACTIVITY_FEATURE: str +COMB_CONSTRUCTION_FEATURE: str +TITLE_VALIDATION_FEATURE: str +STATUS_COMMAND_FEATURE: str +CASE_INSENSITIVE_MATCHING_FEATURE: str + +# Default-group suffix in the per-rule emission convention +# `__test.py{i,}`. +DEFAULT_GROUP_SUFFIX: str + +# The directory `generate` emits `*_test.py{i,}` into (pyproject testpaths). +EMISSION_DIR: str + +def copy_feature_into_pytester(pytester, basename: str) -> str: ... +def write_feature_text(pytester, basename: str, text: str) -> str: ... +def run_beehave_generate(pytester, *args: str) -> int: ... +def read_emitted_pyi(pytester, stem: str) -> str: ... +def read_emitted_py(pytester, stem: str) -> str: ... +def list_emitted_stems(pytester) -> list[str]: ... +def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: ... +def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: ... +def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: ... +def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: ... +def test_function_name_carries_no_uppercase_and_collapses_whitespace( + pytester, +) -> None: ... +def test_feature_background_steps_appear_in_every_emitted_scenario( + pytester, +) -> None: ... +def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: ... +def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: ... +def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: ... +def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: ... diff --git a/tests/e2e/status_test.py b/tests/e2e/status_test.py new file mode 100644 index 0000000..d9b1cd9 --- /dev/null +++ b/tests/e2e/status_test.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import pytest + + +def write_feature_text(pytester, basename: str, text: str) -> str: + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(text) + return str(dst) + + +def write_pyi_stub(pytester, stem: str) -> str: + dst = pytester.path / "tests" / f"{stem}_test.pyi" + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text("") + return str(dst) + + +def run_beehave_status(pytester, *args: str) -> int: + return pytester.run("beehave", "status", *args).ret + + +def status_stdout(pytester) -> str: + result = pytester.run("beehave", "status") + return "\n".join(result.outlines) + + +@pytest.mark.pending +def test_status_exits_zero_when_features_dir_exists(pytester) -> None: + write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") + assert run_beehave_status(pytester) == 0 + + +@pytest.mark.pending +def test_status_reports_feature_file_count(pytester) -> None: + write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") + write_feature_text(pytester, "b.feature", "Feature: B\nScenario: bbbbb\nGiven x\n") + stdout = status_stdout(pytester) + assert "2" in stdout + assert "feature" in stdout.lower() + + +@pytest.mark.pending +def test_status_reports_emitted_stub_count(pytester) -> None: + write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") + write_pyi_stub(pytester, "a_default") + write_pyi_stub(pytester, "a_other") + stdout = status_stdout(pytester) + assert "2" in stdout + assert "stub" in stdout.lower() + + +@pytest.mark.pending +def test_status_exits_two_when_features_dir_missing(pytester) -> None: + assert run_beehave_status(pytester) == 2 diff --git a/tests/e2e/status_test.pyi b/tests/e2e/status_test.pyi new file mode 100644 index 0000000..4293bbe --- /dev/null +++ b/tests/e2e/status_test.pyi @@ -0,0 +1,22 @@ +# E2E contract for the `beehave status` CLI command. +# +# The interview named `status` as one of three CLI commands but flagged its +# detailed behaviour as SOFT (no CIT incident, no laddered constraint grounded +# what it reports). The minimal falsifiable claim - asserted here - is that +# `status` derives a small, deterministic report from disk state without +# stored mutable state of its own: +# - exit 0 when the features directory exists; +# - stdout includes the count of `.feature` files discovered; +# - stdout includes the count of emitted `*_test.pyi` stubs; +# - exit 2 when the features directory is missing (filesystem error). +# The rich v1 stage taxonomy (broken / needs-tests / needs-bodies / needs-fixes +# / ok) is DROPPED - ungrounded by v2 CIT/laddering, and not asserted here. + +def write_feature_text(pytester, basename: str, text: str) -> str: ... +def write_pyi_stub(pytester, stem: str) -> str: ... +def run_beehave_status(pytester, *args: str) -> int: ... +def status_stdout(pytester) -> str: ... +def test_status_exits_zero_when_features_dir_exists(pytester) -> None: ... +def test_status_reports_feature_file_count(pytester) -> None: ... +def test_status_reports_emitted_stub_count(pytester) -> None: ... +def test_status_exits_two_when_features_dir_missing(pytester) -> None: ... diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..245aacd --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import pytest + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "pending: v2 source not yet built; skipped until the build subflow removes the marker", + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + skip = pytest.mark.skip(reason="pending v2 source") + for item in items: + if "pending" in item.keywords: + item.add_marker(skip) diff --git a/tests/integration/idempotency_test.py b/tests/integration/idempotency_test.py new file mode 100644 index 0000000..9890372 --- /dev/null +++ b/tests/integration/idempotency_test.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +BASE_FEATURE = """\ +Feature: Input +Scenario: first scenario +Given anything +""" + +EXTENDED_FEATURE = """\ +Feature: Input +Scenario: first scenario +Given anything + +Scenario: second scenario +Given anything +""" + + +def emit_test_py_for(feature_text: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + return (root / "tests" / "input_default_test.py").read_text() + + +def emit_test_pyi_for(feature_text: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + return (root / "tests" / "input_default_test.pyi").read_text() + + +def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + tests_dir = root / "tests" + tests_dir.mkdir(parents=True) + py_path = tests_dir / "input_default_test.py" + py_path.write_text(existing_py_body) + generate(root) + return py_path.read_text() + + +@pytest.mark.pending +def test_regenerate_preserves_existing_consumer_py_body() -> None: + consumer_marker = "# consumer-authored marker line" + regenerated = regenerate_over_body(BASE_FEATURE, consumer_marker) + assert consumer_marker in regenerated + + +@pytest.mark.pending +def test_regenerate_does_not_emit_py_when_py_present() -> None: + consumer_body = ( + "from beehave import step\n" + "\n" + "def test_first_scenario():\n" + ' with step("Given", "anything"):\n' + " pass\n" + ) + regenerated = regenerate_over_body(BASE_FEATURE, consumer_body) + assert regenerated == consumer_body + + +@pytest.mark.pending +def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: + pyi = emit_test_pyi_for(EXTENDED_FEATURE) + assert "test_second_scenario" in pyi diff --git a/tests/integration/idempotency_test.pyi b/tests/integration/idempotency_test.pyi new file mode 100644 index 0000000..4cb322b --- /dev/null +++ b/tests/integration/idempotency_test.pyi @@ -0,0 +1,21 @@ +# Integration contract for `generate` idempotency at the generation unit. +# +# `generate` re-run on a feature whose `*_test.py` already carries consumer +# bodies preserves those bodies - only the `.pyi` is rewritten (interview L2 +# Modifiability / idempotency). This is the cycle's defining property and +# the protection for the consumer-authored seam. +# +# These tests drive `generate` in-process; the SUT imports live in each body +# (deferred), so the `.pyi` does not import beehave. + +# A minimal feature used as the idempotency input. +BASE_FEATURE: str +# The same feature with one additional scenario (drives `.pyi` re-emission). +EXTENDED_FEATURE: str + +def emit_test_py_for(feature_text: str) -> str: ... +def emit_test_pyi_for(feature_text: str) -> str: ... +def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: ... +def test_regenerate_preserves_existing_consumer_py_body() -> None: ... +def test_regenerate_does_not_emit_py_when_py_present() -> None: ... +def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: ... diff --git a/tests/integration/parsing_test.py b/tests/integration/parsing_test.py new file mode 100644 index 0000000..31881ff --- /dev/null +++ b/tests/integration/parsing_test.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import pytest + +BACKGROUND_WITH_PLACEHOLDER_FEATURE = """\ +Feature: Parsing +Background: + Given a background step with token + +Scenario: scenario +Given anything +""" + +BACKGROUND_CLEAN_FEATURE = """\ +Feature: Parsing +Background: + Given a background step without placeholders + +Scenario: scenario +Given anything +""" + + +def parse_feature_raises(feature_text: str) -> bool: + from beehave.gherkin import parse_feature + + try: + parse_feature(feature_text) + except Exception: + return True + return False + + +@pytest.mark.pending +def test_background_step_with_placeholder_is_parse_error() -> None: + assert parse_feature_raises(BACKGROUND_WITH_PLACEHOLDER_FEATURE) + + +@pytest.mark.pending +def test_background_step_without_placeholder_parses_cleanly() -> None: + assert not parse_feature_raises(BACKGROUND_CLEAN_FEATURE) diff --git a/tests/integration/parsing_test.pyi b/tests/integration/parsing_test.pyi new file mode 100644 index 0000000..71f1a7b --- /dev/null +++ b/tests/integration/parsing_test.pyi @@ -0,0 +1,19 @@ +# Integration contract for `beehave.gherkin` parser-level invariants. +# +# Closes the traceability gap on interview L1: "no placeholders are allowed in +# Background steps" (data-model §2.7 - binding parse-time invariant). A +# `` token inside a Background step is a parse error, not a silent +# acceptance - this is the only parser-level rejection the interview names +# beyond the title rules (which live in `title_derivation_test.pyi`). +# +# These tests drive `beehave.gherkin.parse_feature` in-process; the SUT import +# lives in each body (deferred), so the `.pyi` does not import beehave. + +# A minimal feature whose Background step carries a `` token. +BACKGROUND_WITH_PLACEHOLDER_FEATURE: str +# A minimal feature whose Background step is placeholder-free (the control). +BACKGROUND_CLEAN_FEATURE: str + +def parse_feature_raises(feature_text: str) -> bool: ... +def test_background_step_with_placeholder_is_parse_error() -> None: ... +def test_background_step_without_placeholder_parses_cleanly() -> None: ... diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py new file mode 100644 index 0000000..5b5d41c --- /dev/null +++ b/tests/integration/roundtrip_test.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +ROUND_TRIP_FEATURE = """\ +Feature: Round Trip +Scenario: round trip +Given first step +When second step +Then third step +""" + + +def emit_test_py_for(feature_text: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + return (root / "tests" / "input_default_test.py").read_text() + + +def check_passes_for(feature_text: str, test_py_text: str) -> bool: + from beehave.check import check + + return check(feature_text, test_py_text) + + +@pytest.mark.pending +def test_check_passes_on_freshly_generated_py() -> None: + py_text = emit_test_py_for(ROUND_TRIP_FEATURE) + assert check_passes_for(ROUND_TRIP_FEATURE, py_text) + + +@pytest.mark.pending +def test_check_fails_after_consumer_edits_step_text() -> None: + py_text = emit_test_py_for(ROUND_TRIP_FEATURE) + edited = py_text.replace("first step", "edited step text") + assert not check_passes_for(ROUND_TRIP_FEATURE, edited) + + +@pytest.mark.pending +def test_check_fails_after_consumer_removes_step_block() -> None: + shorter_body = ( + "from beehave import step\n" + "\n" + "def test_round_trip():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ) + assert not check_passes_for(ROUND_TRIP_FEATURE, shorter_body) + + +@pytest.mark.pending +def test_check_passes_after_consumer_adds_body_content() -> None: + body_with_extra = ( + "from beehave import step\n" + "\n" + "def test_round_trip():\n" + ' with step("Given", "first step"):\n' + " x = 1 + 1\n" + ' with step("When", "second step"):\n' + " pass\n" + ' with step("Then", "third step"):\n' + " pass\n" + ) + assert check_passes_for(ROUND_TRIP_FEATURE, body_with_extra) diff --git a/tests/integration/roundtrip_test.pyi b/tests/integration/roundtrip_test.pyi new file mode 100644 index 0000000..eb5e6eb --- /dev/null +++ b/tests/integration/roundtrip_test.pyi @@ -0,0 +1,23 @@ +# Integration contract for the generate -> check round-trip. +# +# `generate` emits a `.pyi` (always) and a `.py` skeleton (when absent); +# `check` walks the emitted `.py` body's `with step(...)` blocks against the +# parsed `.feature` and enforces structural binding. The round-trip is the +# load-bearing claim that generator and checker agree on the contract - +# a freshly generated body must pass `check`, and only consumer edits that +# change the structural binding fields (keyword/text/placeholder-name-set) +# may fail it. +# +# These tests drive the generate+check module path in-process (no subprocess); +# the SUT imports live in each body (deferred), so the `.pyi` does not +# import beehave. + +# A minimal feature used as the round-trip input. +ROUND_TRIP_FEATURE: str + +def emit_test_py_for(feature_text: str) -> str: ... +def check_passes_for(feature_text: str, test_py_text: str) -> bool: ... +def test_check_passes_on_freshly_generated_py() -> None: ... +def test_check_fails_after_consumer_edits_step_text() -> None: ... +def test_check_fails_after_consumer_removes_step_block() -> None: ... +def test_check_passes_after_consumer_adds_body_content() -> None: ... diff --git a/tests/integration/step_cm_test.py b/tests/integration/step_cm_test.py new file mode 100644 index 0000000..a89b918 --- /dev/null +++ b/tests/integration/step_cm_test.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +NOTE_FORMAT = "{keyword} {text}" + + +@pytest.mark.pending +def test_block_body_executes_when_entered() -> None: + from beehave import step + + side_effect: list[str] = [] + with step("Given", "the hive is active"): + side_effect.append("entered") + assert side_effect == ["entered"] + + +@pytest.mark.pending +def test_assertion_inside_then_step_propagates_failure() -> None: + from beehave import step + + with pytest.raises(AssertionError), step("Then", "the hive has honey"): + raise AssertionError + + +@pytest.mark.pending +def test_assertion_inside_then_step_passes_when_truthy() -> None: + from beehave import step + + with step("Then", "the hive has honey"): + assert True + + +@pytest.mark.pending +def test_exception_attributed_to_step_via_add_note() -> None: + from beehave import step + + keyword = "Then" + text = "the hive has honey" + with pytest.raises(AssertionError) as exc_info, step(keyword, text): + raise AssertionError + assert exc_info.value.__notes__ == [ + NOTE_FORMAT.format(keyword=keyword, text=text), + ] + + +@pytest.mark.pending +def test_clean_exit_does_not_add_attribution_note() -> None: + from beehave import step + + with step("Given", "the hive is active"): + pass + + +@pytest.mark.pending +def test_keyword_and_text_are_positional_only() -> None: + from beehave import step + + with pytest.raises(TypeError): + step(keyword="Then", text="the hive has honey") + + +@pytest.mark.pending +def test_placeholders_accepted_as_keyword_arguments() -> None: + from beehave import step + + with step("Given", "the hive has grams", nectar=100): + pass + + +@pytest.mark.pending +def test_all_gherkin_step_keywords_accepted() -> None: + from beehave import step + + keywords = ["Given", "When", "Then", "And", "But", "*"] + for keyword in keywords: + with step(keyword, "any step text"): + pass + + +@pytest.mark.pending +def test_localized_keyword_accepted() -> None: + from beehave import step + + with step("Допустим", "улей активен"): + pass diff --git a/tests/integration/step_cm_test.pyi b/tests/integration/step_cm_test.pyi new file mode 100644 index 0000000..a1ae4c0 --- /dev/null +++ b/tests/integration/step_cm_test.pyi @@ -0,0 +1,23 @@ +# Integration contract for the `step` context manager - the v2 runtime core. +# +# `from beehave import step` - `step(keyword: str, text: str, /, **placeholders)` +# is the executable `with` block that replaces v1's step-definition registry. +# The block RUNS real test code; the `Then` block asserts the outcome; the +# context manager attributes any exception to its step via +# `e.add_note(f"{keyword} {text}")` on `__exit__`, then propagates the exception. +# +# These tests drive the CM in-process; the SUT import lives in each body +# (deferred), so the `.pyi` does not import beehave. + +# The exact `add_note` format the CM appends on `__exit__` exception. +NOTE_FORMAT: str + +def test_block_body_executes_when_entered() -> None: ... +def test_assertion_inside_then_step_propagates_failure() -> None: ... +def test_assertion_inside_then_step_passes_when_truthy() -> None: ... +def test_exception_attributed_to_step_via_add_note() -> None: ... +def test_clean_exit_does_not_add_attribution_note() -> None: ... +def test_keyword_and_text_are_positional_only() -> None: ... +def test_placeholders_accepted_as_keyword_arguments() -> None: ... +def test_all_gherkin_step_keywords_accepted() -> None: ... +def test_localized_keyword_accepted() -> None: ... diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py new file mode 100644 index 0000000..151419f --- /dev/null +++ b/tests/integration/strategy_inference_test.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +INT_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: int column +Given a value of +Then anything + +Examples: + | amount | + | 1 | + | 2 | +""" + +FLOAT_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: float column +Given a value of +Then anything + +Examples: + | amount | + | 1.5 | + | 2.5 | +""" + +BOOL_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: bool column +Given a flag of +Then anything + +Examples: + | flag | + | true | + | false | +""" + +MIXED_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: mixed column +Given a value of +Then anything + +Examples: + | amount | + | 1 | + | 2.5 | + | hello | +""" + +TEXT_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: text column +Given a value of +Then anything + +Examples: + | name | + | alice | + | bob | +""" + +NO_EXAMPLES_FEATURE = """\ +Feature: Strategy Inference +Scenario: no examples +Given a value of +Then anything +""" + + +def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + pyi = (root / "tests" / "input_default_test.pyi").read_text() + needle = f"def test_{scenario_slug}" + start = pyi.find(needle) + if start == -1: + return "" + end = pyi.find("...", start) + if end == -1: + return pyi[start:] + return pyi[start : end + 3] + + +@pytest.mark.pending +def test_all_int_column_infers_int_parameter() -> None: + signature = emitted_function_signature(INT_COLUMN_FEATURE, "int_column") + assert "amount: int" in signature + + +@pytest.mark.pending +def test_all_float_column_infers_float_parameter() -> None: + signature = emitted_function_signature(FLOAT_COLUMN_FEATURE, "float_column") + assert "amount: float" in signature + + +@pytest.mark.pending +def test_all_bool_column_infers_bool_parameter() -> None: + signature = emitted_function_signature(BOOL_COLUMN_FEATURE, "bool_column") + assert "flag: bool" in signature + + +@pytest.mark.pending +def test_mixed_type_column_infers_str_parameter() -> None: + signature = emitted_function_signature(MIXED_COLUMN_FEATURE, "mixed_column") + assert "amount: str" in signature + + +@pytest.mark.pending +def test_text_column_infers_str_parameter() -> None: + signature = emitted_function_signature(TEXT_COLUMN_FEATURE, "text_column") + assert "name: str" in signature + + +@pytest.mark.pending +def test_no_examples_table_infers_str_parameter() -> None: + signature = emitted_function_signature(NO_EXAMPLES_FEATURE, "no_examples") + assert "name: str" in signature diff --git a/tests/integration/strategy_inference_test.pyi b/tests/integration/strategy_inference_test.pyi new file mode 100644 index 0000000..9784795 --- /dev/null +++ b/tests/integration/strategy_inference_test.pyi @@ -0,0 +1,27 @@ +# Integration contract for Examples-table -> Hypothesis strategy -> `.pyi` type. +# +# The generation rule (interview L1): for each Examples column, the strategy is +# inferred from cell values across all rows - all-parseable int -> int, +# all-parseable float -> float, all-parseable bool -> bool, otherwise -> str. +# When a Scenario has no Examples table, placeholders default to `str` +# (decision Q4 - simplest sound default; matches the "otherwise -> str" branch). +# +# These tests drive `beehave.gherkin.parse_feature` + `beehave.generate`'s +# emission path in-process; the SUT imports live in each body (deferred), so +# the `.pyi` does not import beehave. + +# Minimal feature snippets exercising each strategy branch. +INT_COLUMN_FEATURE: str +FLOAT_COLUMN_FEATURE: str +BOOL_COLUMN_FEATURE: str +MIXED_COLUMN_FEATURE: str +TEXT_COLUMN_FEATURE: str +NO_EXAMPLES_FEATURE: str + +def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: ... +def test_all_int_column_infers_int_parameter() -> None: ... +def test_all_float_column_infers_float_parameter() -> None: ... +def test_all_bool_column_infers_bool_parameter() -> None: ... +def test_mixed_type_column_infers_str_parameter() -> None: ... +def test_text_column_infers_str_parameter() -> None: ... +def test_no_examples_table_infers_str_parameter() -> None: ... diff --git a/tests/integration/title_derivation_test.py b/tests/integration/title_derivation_test.py new file mode 100644 index 0000000..6e5b3fe --- /dev/null +++ b/tests/integration/title_derivation_test.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest + +MIN_WORD_COUNT = 2 +MAX_WORD_COUNT = 6 + + +def function_name_for_title(title: str) -> str: + from beehave.gherkin import parse_feature + + feature_text = f"Feature: T\nScenario: {title}\nGiven any step\n" + feature = parse_feature(feature_text) + return feature.children[0].function_name + + +def title_is_valid_in_feature(*titles: str) -> bool: + from beehave.gherkin import parse_feature + + scenarios = "\n".join(f"Scenario: {t}\nGiven any step\n" for t in titles) + feature_text = f"Feature: T\n{scenarios}" + try: + parse_feature(feature_text) + except Exception: + return False + return True + + +@pytest.mark.pending +def test_title_lowered_to_slug() -> None: + assert function_name_for_title("Honey Production") == "test_honey_production" + + +@pytest.mark.pending +def test_whitespace_runs_collapse_to_single_underscore() -> None: + assert function_name_for_title("Honey Production") == "test_honey_production" + + +@pytest.mark.pending +def test_function_name_is_test_underscore_slug() -> None: + assert ( + function_name_for_title("forager returns with nectar") + == "test_forager_returns_with_nectar" + ) + + +@pytest.mark.pending +def test_two_word_title_is_valid() -> None: + assert title_is_valid_in_feature("Honey Production") + + +@pytest.mark.pending +def test_six_word_title_is_valid() -> None: + assert title_is_valid_in_feature("the forager returns to the hive") + + +@pytest.mark.pending +def test_one_word_title_rejected() -> None: + assert not title_is_valid_in_feature("Honey") + + +@pytest.mark.pending +def test_seven_word_title_rejected() -> None: + assert not title_is_valid_in_feature("the forager returns to the busy hive") + + +@pytest.mark.pending +def test_title_with_hyphen_rejected() -> None: + assert not title_is_valid_in_feature("Honey-Production") + + +@pytest.mark.pending +def test_duplicate_titles_case_insensitive_rejected() -> None: + assert not title_is_valid_in_feature("Hive Activity", "hive activity") diff --git a/tests/integration/title_derivation_test.pyi b/tests/integration/title_derivation_test.pyi new file mode 100644 index 0000000..283b1c6 --- /dev/null +++ b/tests/integration/title_derivation_test.pyi @@ -0,0 +1,27 @@ +# Integration contract for title -> slug -> function_name derivation. +# +# The function name IS the scenario's identity (interview L1) - title -> slug +# (lowercased, whitespace runs collapsed to a single underscore) -> +# `test_`. Title rules kept from v1: case-insensitive uniqueness across +# feature/rule/scenario, a 2-6 word-count bound, and a Unicode +# letters/digits/spaces charset. +# +# These tests drive `beehave.gherkin` (parser + title-rule enforcement) +# in-process; the SUT imports live in each body (deferred), so the `.pyi` +# does not import beehave. + +# Title-rule bounds (locked here so each assertion carries the domain value). +MIN_WORD_COUNT: int +MAX_WORD_COUNT: int + +def function_name_for_title(title: str) -> str: ... +def title_is_valid_in_feature(*titles: str) -> bool: ... +def test_title_lowered_to_slug() -> None: ... +def test_whitespace_runs_collapse_to_single_underscore() -> None: ... +def test_function_name_is_test_underscore_slug() -> None: ... +def test_two_word_title_is_valid() -> None: ... +def test_six_word_title_is_valid() -> None: ... +def test_one_word_title_rejected() -> None: ... +def test_seven_word_title_rejected() -> None: ... +def test_title_with_hyphen_rejected() -> None: ... +def test_duplicate_titles_case_insensitive_rejected() -> None: ... From 9bd497edc1532df0a5fb488e004038484b24197d Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:16:23 -0400 Subject: [PATCH 2/9] feat(beehave-v2)!: rewrite as thin Hypothesis layer with Gherkin BDD pipeline Squash-merges the seven-contract v2 build cycle into dev. Modules: - beehave/step.py: step context manager wiring Gherkin steps to Hypothesis - beehave/gherkin.py: Gherkin parse model - beehave/generate.py: test-file generator - beehave/check.py: contract verifier - beehave/status.py: status command - beehave/cli.py: CLI surface - beehave/__init__.py: __version__ = 2.0.0 Removes clean.py, config.py, discover.py, models.py (consolidation to thin layer). Adds .stubtest_allowlist, beehave/py.typed; activates integration + e2e test bodies. BREAKING CHANGE: beehave 2.0.0 is a rewrite; 1.x modules removed. --- .github/workflows/ci.yml | 4 + .stubtest_allowlist | 13 + beehave/__init__.py | 4 +- beehave/check.py | 444 ++------- beehave/clean.py | 78 -- beehave/cli.py | 218 +---- beehave/config.py | 51 -- beehave/discover.py | 157 ---- beehave/generate.py | 401 +++----- beehave/gherkin.py | 863 ++++++------------ beehave/models.py | 83 -- .../__init__.py => beehave/py.typed | 0 beehave/status.py | 399 +------- beehave/step.py | 16 + pyproject.toml | 18 +- tests/conftest.py | 61 -- tests/e2e/check_test.py | 12 +- tests/e2e/generate_test.py | 14 +- tests/e2e/status_test.py | 6 - ...cket_notation_preserved_as_literal_test.py | 1 - .../literal_matching_case_insensitive_test.py | 48 - .../negative_numbers_visible_in_body_test.py | 48 - ...ceholder_matching_case_insensitive_test.py | 59 -- ...ed_placeholder_not_double_captured_test.py | 86 -- .../stub_tests_skip_all_checks_test.py | 5 - .../true_and_one_never_collide_test.py | 1 - tests/features/comb_construction/__init__.py | 0 .../brood_management_test.py | 15 - .../comb_construction/comb_building_test.py | 8 - tests/features/hive_activity/__init__.py | 0 tests/features/hive_activity/default_test.py | 13 - tests/features/hive_activity/foraging_test.py | 8 - .../hive_activity/hive_defense_test.py | 8 - .../hive_activity/hive_foraging_test.py | 9 - tests/features/status_command/__init__.py | 0 .../all_passing_derives_ok_test.py | 63 -- .../all_stubs_derive_stage_test.py | 121 --- .../cross_feature_collisions_detected_test.py | 148 --- ...empty_features_report_no_scenarios_test.py | 65 -- .../exit_codes_reflect_overall_status_test.py | 177 ---- .../json_output_is_machine_readable_test.py | 250 ----- .../ok_feature_collapses_in_output_test.py | 126 --- .../parse_error_captured_as_stage_test.py | 74 -- .../rules_without_scenarios_detected_test.py | 37 - ...rio_statuses_derive_from_discovery_test.py | 164 ---- ...scovery_failure_yields_needs_tests_test.py | 92 -- .../tree_output_shows_rule_hierarchy_test.py | 214 ----- ..._directories_reported_when_flagged_test.py | 104 --- .../unmapped_scenarios_derive_stage_test.py | 109 --- .../violations_derive_stage_test.py | 117 --- .../worst_scenario_wins_test.py | 113 --- tests/features/title_validation/__init__.py | 1 - .../duplicate_titles_are_detected_test.py | 261 ------ .../title_charset_is_validated_test.py | 124 --- ...title_validation_blocks_generation_test.py | 73 -- ...title_violations_included_in_check_test.py | 63 -- .../title_word_count_is_validated_test.py | 108 --- ...valid_titles_produce_no_violations_test.py | 120 --- tests/integration/idempotency_test.py | 5 - tests/integration/parsing_test.py | 8 +- tests/integration/roundtrip_test.py | 8 +- tests/integration/step_cm_test.py | 11 +- tests/integration/strategy_inference_test.py | 8 - tests/integration/title_derivation_test.py | 15 +- tests/test_check.py | 406 -------- tests/test_clean.py | 137 --- tests/test_cli.py | 221 ----- tests/test_config.py | 69 -- tests/test_discover.py | 192 ---- tests/test_generate.py | 287 ------ tests/test_gherkin.py | 476 ---------- uv.lock | 215 ++++- 72 files changed, 785 insertions(+), 7148 deletions(-) create mode 100644 .stubtest_allowlist delete mode 100644 beehave/clean.py delete mode 100644 beehave/config.py delete mode 100644 beehave/discover.py delete mode 100644 beehave/models.py rename tests/features/case_insensitive_matching/__init__.py => beehave/py.typed (100%) create mode 100644 beehave/step.py delete mode 100644 tests/conftest.py delete mode 100644 tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py delete mode 100644 tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py delete mode 100644 tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py delete mode 100644 tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py delete mode 100644 tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py delete mode 100644 tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py delete mode 100644 tests/features/case_insensitive_matching/true_and_one_never_collide_test.py delete mode 100644 tests/features/comb_construction/__init__.py delete mode 100644 tests/features/comb_construction/brood_management_test.py delete mode 100644 tests/features/comb_construction/comb_building_test.py delete mode 100644 tests/features/hive_activity/__init__.py delete mode 100644 tests/features/hive_activity/default_test.py delete mode 100644 tests/features/hive_activity/foraging_test.py delete mode 100644 tests/features/hive_activity/hive_defense_test.py delete mode 100644 tests/features/hive_activity/hive_foraging_test.py delete mode 100644 tests/features/status_command/__init__.py delete mode 100644 tests/features/status_command/all_passing_derives_ok_test.py delete mode 100644 tests/features/status_command/all_stubs_derive_stage_test.py delete mode 100644 tests/features/status_command/cross_feature_collisions_detected_test.py delete mode 100644 tests/features/status_command/empty_features_report_no_scenarios_test.py delete mode 100644 tests/features/status_command/exit_codes_reflect_overall_status_test.py delete mode 100644 tests/features/status_command/json_output_is_machine_readable_test.py delete mode 100644 tests/features/status_command/ok_feature_collapses_in_output_test.py delete mode 100644 tests/features/status_command/parse_error_captured_as_stage_test.py delete mode 100644 tests/features/status_command/rules_without_scenarios_detected_test.py delete mode 100644 tests/features/status_command/scenario_statuses_derive_from_discovery_test.py delete mode 100644 tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py delete mode 100644 tests/features/status_command/tree_output_shows_rule_hierarchy_test.py delete mode 100644 tests/features/status_command/unmapped_directories_reported_when_flagged_test.py delete mode 100644 tests/features/status_command/unmapped_scenarios_derive_stage_test.py delete mode 100644 tests/features/status_command/violations_derive_stage_test.py delete mode 100644 tests/features/status_command/worst_scenario_wins_test.py delete mode 100644 tests/features/title_validation/__init__.py delete mode 100644 tests/features/title_validation/duplicate_titles_are_detected_test.py delete mode 100644 tests/features/title_validation/title_charset_is_validated_test.py delete mode 100644 tests/features/title_validation/title_validation_blocks_generation_test.py delete mode 100644 tests/features/title_validation/title_violations_included_in_check_test.py delete mode 100644 tests/features/title_validation/title_word_count_is_validated_test.py delete mode 100644 tests/features/title_validation/valid_titles_produce_no_violations_test.py delete mode 100644 tests/test_check.py delete mode 100644 tests/test_clean.py delete mode 100644 tests/test_cli.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_discover.py delete mode 100644 tests/test_generate.py delete mode 100644 tests/test_gherkin.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e2b86d..3556267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,10 @@ jobs: - run: uv run ruff format --check . + - run: uv run mypy beehave + + - run: uv run python -m mypy.stubtest beehave --allowlist .stubtest_allowlist + test: name: Test runs-on: ubuntu-latest diff --git a/.stubtest_allowlist b/.stubtest_allowlist new file mode 100644 index 0000000..2216d5c --- /dev/null +++ b/.stubtest_allowlist @@ -0,0 +1,13 @@ +# stubtest allowlist — documented false positives. +# Each line is a regex matched against the error's object path. +# +# beehave.step.step — mypy 2.3.0's @contextmanager inference erases +# positional-only parameter names to None; stubtest then synthesizes +# __arg0/__arg1, which fail to match the runtime-preserved keyword/text. +# The .pyi and .py are faithful (both declare positional-only keyword, text); +# this is a tool quirk, not contract drift. Tracked in +# .cache/beehave-v2/journal.md (build cycle 1 green). Backstopped by the 9 +# tests in tests/integration/step_cm_test.py (behavioral coverage) and +# `mypy --strict beehave` (.pyi-internal consistency). Re-verify manually if +# step's signature changes. +beehave.step.step diff --git a/beehave/__init__.py b/beehave/__init__.py index 9e26b91..dfebabc 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,3 @@ -"""beehave — BDD living documentation in sync.""" +from beehave.step import step as step -__version__ = "0.3.1" +__version__ = "2.0.0" diff --git a/beehave/check.py b/beehave/check.py index 47feeb3..24edd1c 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,369 +1,91 @@ -"""Feature-to-test consistency checker. - -Verifies that every scenario has a matching test function with the correct -placeholders, literals, and example-table bijection. Also runs global title -validation across all ``.feature`` files. -""" - from __future__ import annotations -import sys -from pathlib import Path - -from beehave.config import Config -from beehave.discover import ( - DiscoverError, - discover_tests, - discover_tests_dir_with_paths, -) -from beehave.gherkin import GherkinError, parse_feature, validate_all_titles -from beehave.models import ScenarioInfo, TestInfo, Violation, coerce_example_value - - -def _check_placeholders( - si: ScenarioInfo, - ti: TestInfo, - test_path: str, -) -> list[Violation]: - """Verify every Gherkin placeholder appears in the test body. - - Placeholders are matched case-insensitively (```` matches - ``prefix`` in the test body). - - Args: - si: Parsed scenario information. - ti: Discovered test info. - test_path: Path to the test file for error reporting. - - Returns: - A list of ``Violation`` objects for each missing placeholder. - - """ - if ti.is_stub: - return [] - - violations: list[Violation] = [] - for ph in si.placeholders: - if ph.name.lower() not in {n.lower() for n in ti.body_name_nodes}: - violations.append( - Violation( - path=test_path, - line=ti.line, - error_type="missing-placeholder", - message=f"'{ph.name}' not found in function body", - ) - ) - return violations - - -def _check_literals( - si: ScenarioInfo, - ti: TestInfo, - test_path: str, -) -> list[Violation]: - """Verify every Gherkin step literal appears in the test body. - - Literal values are matched case-insensitively (``"Hello"`` matches - ``"hello"`` in the test body). Numeric literals are converted to - strings before comparison. - - Args: - si: Parsed scenario information. - ti: Discovered test info. - test_path: Path to the test file for error reporting. - - Returns: - A list of ``Violation`` objects for each missing literal. - - """ - if ti.is_stub: - return [] - - violations: list[Violation] = [] - for lit in si.literals: - lowered_constants = {str(c).lower() for c in ti.body_constant_nodes} - if str(lit.value).lower() not in lowered_constants: - violations.append( - Violation( - path=test_path, - line=ti.line, - error_type="missing-literal", - message=f"literal {lit.raw!r} not found in function body", - ) - ) - return violations - - -def _check_examples_bijection( - si: ScenarioInfo, - ti: TestInfo, - test_path: str, - feature_path: str, -) -> list[Violation]: - if not si.is_outline or si.examples is None or ti.is_stub: - return [] - - feature_rows: list[dict[str, object]] = [] - for row in si.examples.rows: - row_dict: dict[str, object] = {} - for i, header in enumerate(si.examples.headers): - row_dict[header] = coerce_example_value(row[i]) - feature_rows.append(row_dict) - - test_rows = list(ti.example_rows) - matched_test: set[int] = set() - matched_feature: set[int] = set() - - for fi, frow in enumerate(feature_rows): - for ti_idx, trow in enumerate(test_rows): - if ti_idx in matched_test: - continue - if frow == trow: - matched_feature.add(fi) - matched_test.add(ti_idx) - break - - violations: list[Violation] = [] - for fi in range(len(feature_rows)): - if fi not in matched_feature: - violations.append( - Violation( - path=feature_path, - line=0, - error_type="example-mismatch", - message=( - f"Examples row {fi + 1} has no matching @example() decorator" - ), - ) - ) - - for ti_idx in range(len(test_rows)): - if ti_idx not in matched_test: - violations.append( - Violation( - path=test_path, - line=0, - error_type="example-mismatch", - message="@example() decorator has no matching Examples row", - ) - ) - - return violations - - -def check_pair( - si: ScenarioInfo, - ti: TestInfo | None, - test_path: str, - feature_path: str, -) -> list[Violation]: - """Check a single scenario against its test function. - - Validates that the test exists, that every Gherkin placeholder appears in - the test body, that every string/numeric literal in the test has a - corresponding Gherkin step token, and that ``@example`` decorators match - Examples table rows. - - Args: - si: Parsed scenario information. - ti: Discovered test info, or ``None`` if no test was found. - test_path: Path to the test file (for error reporting). - feature_path: Path to the feature file (for error reporting). +import ast +from typing import TYPE_CHECKING - Returns: - A list of ``Violation`` objects (empty if everything matches). +from beehave.gherkin import Rule, parse_feature - """ - violations: list[Violation] = [] +if TYPE_CHECKING: + from beehave.gherkin import Scenario, Step - if ti is None: - violations.append( - Violation( - path=feature_path, - line=si.line, - error_type="unmapped-scenario", - message=f"scenario '{si.title}' has no test function", - ) - ) - return violations - violations.extend(_check_placeholders(si, ti, test_path)) - violations.extend(_check_literals(si, ti, test_path)) - violations.extend(_check_examples_bijection(si, ti, test_path, feature_path)) - return violations - - -def _scan_unmapped_tests( - all_test_files: dict[str, dict[str, TestInfo]], - scenarios: dict[str, ScenarioInfo], - test_dir: Path, -) -> list[Violation]: - violations: list[Violation] = [] - for rp, tests in all_test_files.items(): - test_file = test_dir / f"{rp}.py" - for fn, ti in tests.items(): - si = scenarios.get(fn) - if si is None: - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="unmapped-test", - message=f"'{fn}' has no matching scenario", - ) - ) - elif si.rule_path != rp: - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="misplaced-test", - message=( - f"'{fn}' is in {rp}.py but should be in {si.rule_path}.py" - ), - is_warning=True, - ) - ) - return violations - - -def check_single( - feature_path: Path, - config: Config, -) -> list[Violation]: - """Check a single feature file against its test directory. - - Parses the feature, discovers the corresponding test files, and runs - ``check_pair`` for every scenario. - - Args: - feature_path: Path to a ``.feature`` file. - config: The project configuration. - - Returns: - A list of ``Violation`` objects. - - """ +def _step_block_from_item( + item: ast.withitem, +) -> tuple[str, str, set[str]] | None: + call = item.context_expr + if not isinstance(call, ast.Call): + return None + callee = call.func + if not isinstance(callee, ast.Name) or callee.id != "step": + return None + if len(call.args) < 2: + return None try: - scenarios = parse_feature(feature_path, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - return [] - - if not scenarios: - return [] - - first = next(iter(scenarios.values())) - feature_dir = first.feature_path - feature_rel = str(feature_path) - - scenario_by_rule: dict[str, dict[str, ScenarioInfo]] = {} - for fn, si in scenarios.items(): - scenario_by_rule.setdefault(si.rule_path, {})[fn] = si - - test_dir = Path(config.tests_dir) / feature_dir - all_test_files: dict[str, dict[str, TestInfo]] = {} - if test_dir.exists(): - for py_file in sorted(test_dir.glob("*_test.py")): - rp = py_file.stem - try: - all_test_files[rp] = discover_tests(py_file) - except DiscoverError as e: - print(f"Error: {e}", file=sys.stderr) - all_test_files[rp] = {} - - violations: list[Violation] = [] - violations.extend(_scan_unmapped_tests(all_test_files, scenarios, test_dir)) - - for fn, si in scenarios.items(): - test_file = test_dir / f"{si.rule_path}.py" - rp_tests = all_test_files.get(si.rule_path, {}) - ti = rp_tests.get(fn) - violations.extend(check_pair(si, ti, str(test_file), feature_rel)) - - return violations - - -def check_all(config: Config) -> list[Violation]: - """Run the full project-wide consistency check. - - Parses every ``.feature`` file, discovers every test function, and then - for each scenario verifies placeholder coverage, literal mapping, example - bijection, file placement, and unmapped tests. Finally runs - ``validate_all_titles`` to catch title-level problems. - - Args: - config: The project configuration. - - Returns: - A consolidated list of every ``Violation`` found. - - """ - features_dir = Path(config.features_dir) - if not features_dir.exists(): - print( - f"Error: features directory '{config.features_dir}' not found", - file=sys.stderr, - ) - return [] - - all_scenarios: dict[str, ScenarioInfo] = {} - feature_paths: dict[str, Path] = {} - - seen_fn: dict[str, str] = {} - for feature_file in sorted(features_dir.rglob("*.feature")): - try: - scenarios = parse_feature( - feature_file, - config, - seen_function_names=seen_fn, - skip_title_validation=True, - ) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) + keyword = ast.literal_eval(call.args[0]) + text = ast.literal_eval(call.args[1]) + except ValueError, SyntaxError: + return None + if not isinstance(keyword, str) or not isinstance(text, str): + return None + names = {kw.arg for kw in call.keywords if kw.arg is not None} + return (keyword, text, names) + + +def _step_blocks( + function: ast.FunctionDef, +) -> list[tuple[str, str, set[str]]]: + blocks: list[tuple[str, str, set[str]]] = [] + for stmt in function.body: + if not isinstance(stmt, ast.With): continue - for fn in scenarios: - feature_paths[fn] = feature_file - all_scenarios.update(scenarios) - - tests_dir = Path(config.tests_dir) - test_file_map = discover_tests_dir_with_paths(tests_dir) - - violations: list[Violation] = [] - for fn, si in all_scenarios.items(): - feature_rel = str(feature_paths[fn]) - test_file = Path(config.tests_dir) / si.feature_path / f"{si.rule_path}.py" - entry = test_file_map.get(fn) - ti = entry[0] if entry else None - violations.extend(check_pair(si, ti, str(test_file), feature_rel)) - - for fn, (ti, test_file) in test_file_map.items(): - si = all_scenarios.get(fn) - if si is None: - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="unmapped-test", - message=f"'{fn}' has no matching scenario", - ) - ) - else: - expected = Path(config.tests_dir) / si.feature_path / f"{si.rule_path}.py" - if test_file.resolve() != expected.resolve(): - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="misplaced-test", - message=( - f"'{fn}' is in {test_file.name} " - f"but should be in {expected.name}" - ), - is_warning=True, - ) - ) - - violations.extend(validate_all_titles(config)) - - return violations + for item in stmt.items: + block = _step_block_from_item(item) + if block is not None: + blocks.append(block) + return blocks + + +def _step_matches( + block: tuple[str, str, set[str]], + step: Step, +) -> bool: + keyword, text, names = block + if keyword.lower() != step.keyword.lower(): + return False + if text != step.text: + return False + if names != {p.name for p in step.placeholders}: + return False + return True + + +def _scenario_matches( + scenario: Scenario, + blocks_by_function: dict[str, list[tuple[str, str, set[str]]]], +) -> bool: + blocks = blocks_by_function.get(scenario.function_name, []) + if len(blocks) != len(scenario.steps): + return False + return all( + _step_matches(block, step) + for block, step in zip(blocks, scenario.steps, strict=True) + ) + + +def check(feature_text: str, test_py_text: str) -> bool: + feature = parse_feature(feature_text) + try: + tree = ast.parse(test_py_text) + except SyntaxError: + return False + blocks_by_function = { + node.name: _step_blocks(node) + for node in tree.body + if isinstance(node, ast.FunctionDef) + } + for child in feature.children: + scenarios = child.children if isinstance(child, Rule) else [child] + for scenario in scenarios: + if not _scenario_matches(scenario, blocks_by_function): + return False + return True diff --git a/beehave/clean.py b/beehave/clean.py deleted file mode 100644 index b64cdf8..0000000 --- a/beehave/clean.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import ast -import sys -from pathlib import Path - -from beehave.config import Config -from beehave.discover import discover_tests -from beehave.gherkin import GherkinError, parse_feature - - -def clean_unmapped( - feature_path: str, - config: Config, - force: bool = False, -) -> None: - fpath = Path(config.features_dir) / f"{feature_path}.feature" - if not fpath.exists(): - print(f"Error: Feature file not found: {fpath}", file=sys.stderr) - raise SystemExit(1) from None - - try: - scenarios = parse_feature(fpath, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - raise SystemExit(1) from None - - if not scenarios: - return - - feature_dir = next(iter(scenarios.values())).feature_path - - rule_paths = {si.rule_path for si in scenarios.values()} - for rp in rule_paths: - test_file = Path(config.tests_dir) / feature_dir / f"{rp}.py" - if not test_file.exists(): - continue - - rp_scenarios = {fn: si for fn, si in scenarios.items() if si.rule_path == rp} - tests = discover_tests(test_file) - unmapped_fns = [fn for fn in tests if fn not in rp_scenarios] - - if not unmapped_fns: - continue - - non_stub = [fn for fn in unmapped_fns if not tests[fn].is_stub] - if non_stub and not force: - for fn in non_stub: - print( - f"Warning: '{fn}' is not a stub. " - f"Use --force to remove non-stub functions.", - ) - continue - - source = test_file.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(test_file)) - - removed: set[str] = set() - tree.body = [ - node - for node in tree.body - if not ( - isinstance(node, ast.FunctionDef) - and node.name in unmapped_fns - and not removed.add(node.name) - ) - ] - - if not removed: - continue - - new_source = ast.unparse(tree) - test_file.write_text(new_source + "\n", encoding="utf-8") - names = ", ".join(sorted(removed)) - print( - f"Removed {len(removed)} unmapped function(s) " - f"from {test_file.name}: {names}" - ) diff --git a/beehave/cli.py b/beehave/cli.py index cc18b62..e8a2739 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,187 +1,41 @@ from __future__ import annotations -import argparse import sys +from collections.abc import Sequence from pathlib import Path -from beehave.check import check_all, check_single -from beehave.clean import clean_unmapped -from beehave.config import load_config -from beehave.discover import discover_tests -from beehave.generate import generate_stubs -from beehave.gherkin import GherkinError, parse_feature -from beehave.status import compute_status - - -def cmd_generate(args: argparse.Namespace) -> None: - config = load_config() - generate_stubs(args.feature, config) - - -def cmd_check(args: argparse.Namespace) -> None: - config = load_config() - if args.feature: - fpath = Path(config.features_dir) / f"{args.feature}.feature" - violations = check_single(fpath, config) - else: - violations = check_all(config) - - for v in violations: - print(v) - - if any(not v.is_warning for v in violations): - raise SystemExit(1) - - -def cmd_clean(args: argparse.Namespace) -> None: - config = load_config() - clean_unmapped(args.feature, config, force=args.force) - - -def cmd_list(args: argparse.Namespace) -> None: - config = load_config() - features_dir = Path(config.features_dir) - if not features_dir.exists(): - return - - for feature_file in sorted(features_dir.rglob("*.feature")): - try: - scenarios = parse_feature(feature_file, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - continue - - if not scenarios: - continue - - feature_path = str(feature_file.relative_to(features_dir).with_suffix("")) - title = next(iter(scenarios.values())).feature_title - print(f"{feature_path}: {title}") - - if not args.verbose: - continue - - print(f" path: {feature_file}") - print(f" scenarios: {len(scenarios)}") - - rule_groups: dict[str, list[str]] = {} - top_level: list[str] = [] - for fn, si in scenarios.items(): - if si.rule_path == "default_test": - top_level.append(fn) - else: - rule_name = si.rule_path.removesuffix("_test") - rule_groups.setdefault(rule_name, []).append(fn) - - if top_level: - print(f" top-level: {len(top_level)}") - for rule_name, fns in rule_groups.items(): - print(f" rules: {rule_name} ({len(fns)})") - - test_dir = Path(config.tests_dir) / next(iter(scenarios.values())).feature_path - stub_count = 0 - impl_count = 0 - for si in scenarios.values(): - tf = test_dir / f"{si.rule_path}.py" - tests = discover_tests(tf) - ti = tests.get(si.function_name) - if ti is not None and not ti.is_stub: - impl_count += 1 - else: - stub_count += 1 - total = stub_count + impl_count - if impl_count == 0: - print(f" stubs: {total}/{total} (all stubs)") - elif stub_count == 0: - print(f" stubs: 0/{total} (all implemented)") - else: - print(f" stubs: {stub_count}/{total} ({impl_count} implemented)") - - -def cmd_status(args: argparse.Namespace) -> None: - config = load_config() - compute_status( - config, - json_output=args.json, - include_unmapped=args.include_unmapped, - ) - - -def main(argv: list[str] | None = None) -> None: - parser = argparse.ArgumentParser( - prog="beehave", - description="BDD living documentation in sync", - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - gen = subparsers.add_parser( - "generate", - help="Generate test stubs from a feature file", - ) - gen.add_argument( - "feature", - help=("Feature path without extension, relative to features_dir"), - ) - gen.set_defaults(func=cmd_generate) - - chk = subparsers.add_parser( - "check", - help="Check consistency between features and tests", - ) - chk.add_argument( - "feature", - nargs="?", - default=None, - help=("Feature path (optional; checks all if omitted)"), - ) - chk.set_defaults(func=cmd_check) - - cln = subparsers.add_parser( - "clean", - help="Remove unmapped test functions", - ) - cln.add_argument( - "feature", - help="Feature path without extension", - ) - cln.add_argument( - "--force", - action="store_true", - help="Remove non-stub functions without warning", - ) - cln.set_defaults(func=cmd_clean) - - lst = subparsers.add_parser( - "list", - help="List all features with their paths", - ) - lst.add_argument( - "--verbose", - "-v", - action="store_true", - help="Show scenario counts, rules, and stub status", - ) - lst.set_defaults(func=cmd_list) - - sts = subparsers.add_parser( - "status", - help="Show development status of all features", - ) - sts.add_argument( - "--json", - action="store_true", - help="Output status report as JSON", - ) - sts.add_argument( - "--include-unmapped", - action="store_true", - help="Report test directories with no matching feature file", - ) - sts.set_defaults(func=cmd_status) - - args = parser.parse_args(argv) - args.func(args) - - -if __name__ == "__main__": - main() +from beehave.check import check +from beehave.generate import generate +from beehave.status import status + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args: + return 2 + cmd = args[0] + if cmd == "generate": + generate(Path.cwd()) + return 0 + if cmd == "status": + return status(Path.cwd()) + if cmd == "check": + return _check_all(Path.cwd()) + return 2 + + +def _check_all(root: Path) -> int: + features_dir = root / "docs" / "features" + if not features_dir.is_dir(): + return 1 + test_py_text = _read_test_py(root / "tests") + for feature_path in sorted(features_dir.glob("*.feature")): + if not check(feature_path.read_text(), test_py_text): + return 1 + return 0 + + +def _read_test_py(tests_dir: Path) -> str: + if not tests_dir.is_dir(): + return "" + return "\n".join(path.read_text() for path in sorted(tests_dir.glob("*_test.py"))) diff --git a/beehave/config.py b/beehave/config.py deleted file mode 100644 index 82b61d9..0000000 --- a/beehave/config.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import tomllib -from dataclasses import dataclass -from pathlib import Path - -VALID_STRATEGIES = ("text", "integers", "floats", "booleans") - -_STRATEGY_MAP = { - "text": "st.text()", - "integers": "st.integers()", - "floats": "st.floats()", - "booleans": "st.booleans()", -} - - -@dataclass(frozen=True) -class Config: - features_dir: str = "docs/features" - tests_dir: str = "tests/features" - default_strategy: str = "text" - background_check_numeric: bool = True - background_check_string: bool = True - - def __post_init__(self) -> None: - if self.default_strategy not in VALID_STRATEGIES: - raise ValueError( - f"Invalid default_strategy '{self.default_strategy}'. " - f"Valid options: {', '.join(VALID_STRATEGIES)}" - ) - - @property - def default_strategy_expr(self) -> str: - return _STRATEGY_MAP[self.default_strategy] - - -def load_config(project_root: Path | None = None) -> Config: - root = project_root or Path.cwd() - pyproject = root / "pyproject.toml" - if not pyproject.exists(): - return Config() - with open(pyproject, "rb") as f: - data = tomllib.load(f) - tool = data.get("tool", {}).get("beehave", {}) - return Config( - features_dir=tool.get("features_dir", "docs/features"), - tests_dir=tool.get("tests_dir", "tests/features"), - default_strategy=tool.get("default_strategy", "text"), - background_check_numeric=tool.get("background_check_numeric", True), - background_check_string=tool.get("background_check_string", True), - ) diff --git a/beehave/discover.py b/beehave/discover.py deleted file mode 100644 index 82ccb96..0000000 --- a/beehave/discover.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import ast -from pathlib import Path - -from beehave.models import TestInfo - - -class DiscoverError(Exception): - pass - - -def _is_stub_body(body: list[ast.stmt]) -> bool: - if len(body) == 1: - node = body[0] - if isinstance(node, ast.Pass): - return True - if ( - isinstance(node, ast.Expr) - and isinstance(node.value, ast.Constant) - and node.value.value is ... - ): - return True - return False - - -def _extract_body_nodes( - body: list[ast.stmt], -) -> tuple[tuple[str, ...], tuple[object, ...]]: - """Walk a test function body and collect all names and constants. - - The first statement in a multi-statement body is skipped if it is a - docstring. ``UnaryOp`` nodes wrapping constants are folded (e.g. - ``-5`` becomes the constant ``-5``, not ``5`` with a negation flag). - - Args: - body: The list of AST statements in the function body. - - Returns: - A 2-tuple of ``(names, constants)``, each sorted. - - """ - check_nodes: list[ast.stmt] = [] - for node in body: - if ( - not check_nodes - and isinstance(node, ast.Expr) - and isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) - and len(body) > 1 - ): - continue - check_nodes.append(node) - - names: set[str] = set() - constants: set[object] = set() - - for node in ast.walk(ast.Module(body=check_nodes, type_ignores=[])): - if isinstance(node, ast.Name): - names.add(node.id) - elif isinstance(node, ast.Constant): - constants.add(node.value) - elif isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant): - if isinstance(node.op, ast.USub): - constants.add(-node.operand.value) - elif isinstance(node.op, ast.UAdd): - constants.add(node.operand.value) - - return tuple(sorted(names)), tuple(sorted(constants, key=repr)) - - -def _extract_given_kwargs( - decorators: list[ast.expr], -) -> tuple[str, ...]: - kwargs: list[str] = [] - for dec in decorators: - if not isinstance(dec, ast.Call): - continue - is_given = ( - isinstance(dec.func, ast.Attribute) and dec.func.attr == "given" - ) or (isinstance(dec.func, ast.Name) and dec.func.id == "given") - if is_given: - for kw in dec.keywords: - if kw.arg: - kwargs.append(kw.arg) - return tuple(kwargs) - - -def _extract_example_rows( - decorators: list[ast.expr], -) -> tuple[dict[str, object], ...]: - rows: list[dict[str, object]] = [] - for dec in decorators: - if ( - isinstance(dec, ast.Call) - and isinstance(dec.func, ast.Name) - and dec.func.id == "example" - ): - row: dict[str, object] = {} - for kw in dec.keywords: - if kw.arg and isinstance(kw.value, ast.Constant): - row[kw.arg] = kw.value.value - rows.append(row) - return tuple(rows) - - -def discover_tests(test_file: Path) -> dict[str, TestInfo]: - if not test_file.exists(): - return {} - - try: - source = test_file.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(test_file)) - except SyntaxError as e: - raise DiscoverError(f"{test_file}:{e.lineno}: {e.msg}") from e - - result: dict[str, TestInfo] = {} - - for node in tree.body: - if not isinstance(node, ast.FunctionDef): - continue - if not node.name.startswith("test_"): - continue - - decorators = list(node.decorator_list) - given_kwargs = _extract_given_kwargs(decorators) - example_rows = _extract_example_rows(decorators) - is_stub = _is_stub_body(node.body) - body_names, body_constants = _extract_body_nodes(node.body) - - result[node.name] = TestInfo( - function_name=node.name, - given_kwargs=given_kwargs, - example_rows=example_rows, - body_name_nodes=body_names, - body_constant_nodes=body_constants, - is_stub=is_stub, - line=node.lineno, - ) - - return result - - -def discover_tests_dir_with_paths( - tests_dir: Path, -) -> dict[str, tuple[TestInfo, Path]]: - all_tests: dict[str, tuple[TestInfo, Path]] = {} - if not tests_dir.exists(): - return all_tests - for py_file in tests_dir.rglob("*_test.py"): - try: - tests = discover_tests(py_file) - for fn, ti in tests.items(): - all_tests[fn] = (ti, py_file) - except DiscoverError: - continue - return all_tests diff --git a/beehave/generate.py b/beehave/generate.py index 89eaeb0..55b0cfa 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -1,297 +1,138 @@ -"""Test stub generator. - -Reads a ``.feature`` file and produces corresponding pytest-beehave test stubs, -complete with Hypothesis strategies inferred from Examples tables and Gherkin -placeholders. -""" - from __future__ import annotations -import ast -import contextlib -import re -import sys from pathlib import Path +from typing import TYPE_CHECKING -from beehave.config import Config -from beehave.discover import discover_tests -from beehave.gherkin import GherkinError, parse_feature, validate_all_titles -from beehave.models import ExamplesTable, ScenarioInfo, coerce_example_value - - -def _infer_strategy_from_examples( - header: str, - examples: object, -) -> str: - table: ExamplesTable = examples - col_idx = table.headers.index(header) - values = [row[col_idx] for row in table.rows] - - types: set[str] = set() - for v in values: - if re.match(r"^-?\d+$", v): - types.add("integers") - elif re.match(r"^-?\d+\.\d+$", v): - types.add("floats") - elif v.lower() in ("true", "false"): - types.add("booleans") - else: - types.add("text") - - if types == {"integers"}: - return "st.integers()" - if types == {"floats"}: - return "st.floats()" - if types == {"booleans"}: - return "st.booleans()" - return "st.text()" - - -def _resolve_strategy( - placeholder_name: str, - scenario: ScenarioInfo, - existing_strategies: set[str], - config: Config, -) -> str: - if placeholder_name in existing_strategies: - return placeholder_name - - if ( - scenario.is_outline - and scenario.examples - and placeholder_name in scenario.examples.headers - ): - return _infer_strategy_from_examples(placeholder_name, scenario.examples) - - return config.default_strategy_expr - - -def _generate_function( - scenario: ScenarioInfo, - existing_strategies: set[str], - config: Config, -) -> str: - lines: list[str] = [] - - has_params = bool(scenario.placeholders) - - if has_params: - given_kwargs = [] - for ph in scenario.placeholders: - strategy = _resolve_strategy(ph.name, scenario, existing_strategies, config) - given_kwargs.append(f"{ph.name}={strategy}") +from beehave.gherkin import Rule, parse_feature - if scenario.is_outline and scenario.examples: - for row in scenario.examples.rows: - row_parts = [] - for i, header in enumerate(scenario.examples.headers): - val = coerce_example_value(row[i]) - if isinstance(val, str): - row_parts.append(f'{header}="{val}"') - elif isinstance(val, bool): - row_parts.append(f"{header}={val}") - else: - row_parts.append(f"{header}={val}") - lines.append(f"@example({', '.join(row_parts)})") +if TYPE_CHECKING: + from beehave.gherkin import Examples, Scenario, Step - lines.append(f"@given({', '.join(given_kwargs)})") - params = ", ".join(ph.name for ph in scenario.placeholders) - lines.append(f"def {scenario.function_name}({params}):") - else: - lines.append(f"def {scenario.function_name}():") +def _slug_from(title: str) -> str: + return "_".join(title.split()).lower() - lines.append(" ...") - lines.append("") - return "\n".join(lines) +def _is_int(value: str) -> bool: + try: + int(value) + except ValueError: + return False + return True -def _build_import_block( - scenarios: dict[str, ScenarioInfo], -) -> list[str]: - needs_given = any(s.placeholders for s in scenarios.values()) - needs_example = any(s.is_outline for s in scenarios.values()) - needs_st = needs_given +def _is_float(value: str) -> bool: + try: + float(value) + except ValueError: + return False + return True + + +def _is_bool(value: str) -> bool: + return value.lower() in ("true", "false") + + +def _placeholder_names(steps: list[Step]) -> list[str]: + seen: set[str] = set() + names: list[str] = [] + for step in steps: + for placeholder in step.placeholders: + if placeholder.name not in seen: + seen.add(placeholder.name) + names.append(placeholder.name) + return names + + +def _infer_param_type(name: str, examples: Examples | None) -> str: + if examples is None: + return "str" + values = [row[name] for row in examples.rows if name in row] + if not values: + return "str" + if all(_is_int(v) for v in values): + return "int" + if all(_is_float(v) for v in values): + return "float" + if all(_is_bool(v) for v in values): + return "bool" + return "str" + + +def _signature_params(scenario: Scenario) -> str: parts: list[str] = [] - if needs_given: - parts.append("given") - if needs_example: - parts.append("example") - if needs_st: - parts.append("strategies as st") + for name in _placeholder_names(scenario.steps): + parts.append(f"{name}: {_infer_param_type(name, scenario.examples)}") + return ", ".join(parts) - if not parts: - return [] +def _render_pyi(scenarios: list[Scenario]) -> str: lines: list[str] = [] - lines.append(f"from hypothesis import {', '.join(parts)}") - lines.append("") - return lines - - -def _parse_existing_imports(source: str) -> set[str]: - try: - tree = ast.parse(source) - except SyntaxError: - return set() - - imported: set[str] = set() - for node in tree.body: - if ( - isinstance(node, ast.ImportFrom) - and node.module - and "hypothesis" in node.module - ): - for alias in node.names: - imported.add(alias.asname or alias.name) - return imported - - -def _update_import_line( - source: str, - needed: set[str], -) -> str: - lines = source.split("\n") - for i, line in enumerate(lines): - if not line.startswith("from hypothesis import"): - continue - - current = line.replace("from hypothesis import", "").strip() - current_set = {p.strip() for p in current.split(",")} - current_set.update(needed) - - ordered: list[str] = [] - for name in ["given", "example", "settings"]: - if name in current_set: - ordered.append(name) - if "strategies as st" in current_set: - ordered.append("strategies as st") - - lines[i] = f"from hypothesis import {', '.join(ordered)}" - break - - return "\n".join(lines) - - -def _write_file( - test_file: Path, - scenarios: dict[str, ScenarioInfo], - config: Config, + for scenario in scenarios: + params = _signature_params(scenario) + lines.append(f"def {scenario.function_name}({params}) -> None: ...") + return "\n".join(lines) + "\n" + + +def _step_block(step: Step) -> str: + kwargs = "".join(f", {p.name}={p.name}" for p in step.placeholders) + return f" with step({step.keyword!r}, {step.text!r}{kwargs}):" + + +def _render_py(scenarios: list[Scenario]) -> str: + lines: list[str] = ["from beehave import step"] + for scenario in scenarios: + params = _signature_params(scenario) + lines.append("") + lines.append("") + lines.append(f"def {scenario.function_name}({params}) -> None:") + for step in scenario.steps: + lines.append(_step_block(step)) + lines.append(" pass") + if not scenario.steps: + lines.append(" pass") + return "\n".join(lines) + "\n" + + +def _emit_group( + *, + tests_dir: Path, + stem: str, + scenarios: list[Scenario], ) -> None: - test_file.parent.mkdir(parents=True, exist_ok=True) - init_file = test_file.parent / "__init__.py" - if not init_file.exists(): - init_file.touch() - - existing_functions: set[str] = set() - existing_strategies: set[str] = set() - existing_imports: set[str] = set() - - if test_file.exists(): - with contextlib.suppress(Exception): - existing_functions = set(discover_tests(test_file).keys()) - - try: - source = test_file.read_text(encoding="utf-8") - existing_imports = _parse_existing_imports(source) - tree = ast.parse(source) - for node in tree.body: - if ( - isinstance(node, ast.Assign) - and len(node.targets) == 1 - and isinstance(node.targets[0], ast.Name) - ): - existing_strategies.add(node.targets[0].id) - except SyntaxError: - pass - - new_functions: list[str] = [] - for fn, scenario in scenarios.items(): - if fn not in existing_functions: - new_functions.append( - _generate_function(scenario, existing_strategies, config) + pyi_path = tests_dir / f"{stem}_test.pyi" + py_path = tests_dir / f"{stem}_test.py" + pyi_path.write_text(_render_pyi(scenarios)) + if not py_path.exists(): + py_path.write_text(_render_py(scenarios)) + + +def generate(root: Path) -> None: + features_dir = root / "docs" / "features" + tests_dir = root / "tests" + tests_dir.mkdir(parents=True, exist_ok=True) + + for feature_path in sorted(features_dir.glob("*.feature")): + feature = parse_feature(feature_path.read_text()) + feature_slug = _slug_from(feature_path.stem) + + default_scenarios: list[Scenario] = [] + rules: list[Rule] = [] + for child in feature.children: + if isinstance(child, Rule): + rules.append(child) + else: + default_scenarios.append(child) + + if default_scenarios: + _emit_group( + tests_dir=tests_dir, + stem=f"{feature_slug}_default", + scenarios=default_scenarios, + ) + for rule in rules: + _emit_group( + tests_dir=tests_dir, + stem=f"{feature_slug}_{_slug_from(rule.name)}", + scenarios=rule.children, ) - - if not new_functions: - return - - needed: set[str] = set() - if any(s.placeholders for s in scenarios.values()): - needed.add("given") - needed.add("strategies as st") - if any(s.is_outline for s in scenarios.values()): - needed.add("example") - - if test_file.exists(): - source = test_file.read_text(encoding="utf-8") - missing = needed - existing_imports - if missing: - source = _update_import_line(source, missing) - - with open(test_file, "w", encoding="utf-8") as f: - f.write(source.rstrip("\n") + "\n\n") - for func in new_functions: - f.write(func + "\n") - else: - import_block = _build_import_block(scenarios) - with open(test_file, "w", encoding="utf-8") as f: - for line in import_block: - f.write(line + "\n") - for func in new_functions: - f.write(func + "\n") - - -def generate_stubs( - feature_path: str, - config: Config, -) -> None: - """Generate test stubs for a feature file. - - Runs ``validate_all_titles`` as a pre-flight gate: if any title in the - project is invalid or duplicated, generation is refused. Then parses the - requested feature, builds import blocks, infers Hypothesis strategies from - Examples tables and Gherkin placeholders, and writes the test stubs to - ``tests/features//``. - - Args: - feature_path: The feature file stem (e.g. ``"hive_activity"``). - config: The project configuration. - - Raises: - SystemExit: When pre-flight title validation fails or the feature file - does not exist. - - """ - violations = validate_all_titles(config) - if violations: - for v in violations: - print(str(v), file=sys.stderr) - raise SystemExit(1) - - fpath = Path(config.features_dir) / f"{feature_path}.feature" - if not fpath.exists(): - print(f"Error: Feature file not found: {fpath}", file=sys.stderr) - raise SystemExit(1) from None - - try: - scenarios = parse_feature(fpath, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - raise SystemExit(1) from None - - if not scenarios: - return - - feature_dir = next(iter(scenarios.values())).feature_path - - rule_groups: dict[str, dict[str, ScenarioInfo]] = {} - for fn, si in scenarios.items(): - rp = si.rule_path - if rp not in rule_groups: - rule_groups[rp] = {} - rule_groups[rp][fn] = si - - for rp, group in rule_groups.items(): - test_file = Path(config.tests_dir) / feature_dir / f"{rp}.py" - _write_file(test_file, group, config) diff --git a/beehave/gherkin.py b/beehave/gherkin.py index 33d7675..e215099 100644 --- a/beehave/gherkin.py +++ b/beehave/gherkin.py @@ -1,644 +1,335 @@ -"""Gherkin feature file parser and title validation. - -Parses .feature files into ScenarioInfo objects, extracting steps, placeholders, -literals, and examples tables. Also provides global title validation that checks -all feature, rule, and scenario titles for uniqueness, character set, and word -count constraints. -""" - from __future__ import annotations -import builtins -import keyword import re -from pathlib import Path +from typing import Any, cast -from gherkin import Parser +from gherkin.parser import Parser -from beehave.config import Config -from beehave.models import ( - ExamplesTable, - Literal, - ParsedStep, - Placeholder, - ScenarioInfo, - Violation, -) -_TITLE_RE = re.compile(r"^[\w\s]+$") -_PLACEHOLDER_RE = re.compile(r"<([^>]+)>") -_NUMERIC_LITERAL_RE = re.compile(r"^-?\d+$") -_QUOTED_STRING_RE = re.compile(r"""(?:"([^"]*)"|'([^']*)')""") +class Placeholder: + name: str -class GherkinError(Exception): - """Raised when a feature file is missing, malformed, or has invalid titles.""" +class DataTable: + headers: list[str] | None + rows: list[list[str]] -def _validate_title(title: str, kind: str, context: str = "") -> None: - if not title or not title.strip(): - raise GherkinError(f"{kind} title must be non-empty. {context}") - if not _TITLE_RE.match(title): - raise GherkinError( - f"{kind} title '{title}' contains invalid characters. " - f"Only Unicode letters, digits, and spaces are allowed. " - f"{context}" - ) +class Step: + keyword: str + text: str + placeholders: list[Placeholder] + docstring: str | None + data_table: DataTable | None -def _derive_path_slug(title: str) -> str: - return re.sub(r"\s+", "_", title.strip()).lower() +class Examples: + headers: list[str] + rows: list[dict[str, str]] -def _derive_function_name(title: str) -> str: - trimmed = title.strip() - collapsed = re.sub(r"\s+", "_", trimmed).lower() - name = f"test_{collapsed}" - if not name.isidentifier(): - raise GherkinError( - f"Derived function name '{name}' is not a valid Python " - f"identifier from scenario title '{title}'" - ) - return name +class Background: + steps: list[Step] -_derive_feature_path = _derive_rule_path = _derive_path_slug +class Scenario: + title: str + slug: str + function_name: str + tags: list[str] + keyword: str + steps: list[Step] + examples: Examples | None -def _extract_placeholders(text: str) -> tuple[Placeholder, ...]: - seen: set[str] = set() - result: list[Placeholder] = [] - for match in _PLACEHOLDER_RE.finditer(text): - name = match.group(1) - if name in seen: - continue - if not name.isidentifier(): - raise GherkinError( - f"Placeholder '<{name}>' is not a valid Python identifier" - ) - if keyword.iskeyword(name): - raise GherkinError(f"Placeholder '<{name}>' is a Python keyword") - if hasattr(builtins, name): - raise GherkinError(f"Placeholder '<{name}>' shadows a Python builtin") - seen.add(name) - result.append(Placeholder(name=name)) - return tuple(result) +class Rule: + name: str + tags: list[str] + background: Background | None + children: list[Scenario] -def _extract_literals(text: str) -> tuple[Literal, ...]: - """Extract numeric and quoted-string literals from step text. +class Feature: + name: str + tags: list[str] + background: Background | None + children: list[Rule | Scenario] - Quoted strings that match placeholder syntax (e.g. ``""``) are - skipped — they represent quotes around a placeholder, not a literal - value. - Args: - text: The text of a Gherkin step. +_PLACEHOLDER_RE = re.compile(r"<([^>]+)>") +_WHITESPACE_RE = re.compile(r"\s+") - Returns: - A tuple of ``Literal`` objects (may be empty). +_MIN_WORD_COUNT = 2 +_MAX_WORD_COUNT = 6 - """ - result: list[Literal] = [] - for token in text.split(): - if _NUMERIC_LITERAL_RE.match(token): - result.append(Literal(value=int(token), raw=token)) - for match in _QUOTED_STRING_RE.finditer(text): - value = match.group(1) if match.group(1) is not None else match.group(2) - if _PLACEHOLDER_RE.match(value): - continue - result.append(Literal(value=value, raw=match.group(0))) - return tuple(result) +def _make_placeholder(name: str) -> Placeholder: + p = Placeholder() + p.name = name + return p -def _parse_step(step_data: dict) -> ParsedStep: - text = step_data["text"] - return ParsedStep( - keyword=step_data["keyword"].strip(), - text=text, - placeholders=_extract_placeholders(text), - literals=_extract_literals(text), - line=step_data["location"]["line"], - ) +def _make_data_table(headers: list[str] | None, rows: list[list[str]]) -> DataTable: + dt = DataTable() + dt.headers = headers + dt.rows = rows + return dt -def _parse_examples( - examples_list: list[dict], -) -> ExamplesTable | None: - if not examples_list: - return None - ex = examples_list[0] - headers = tuple(h["value"] for h in ex.get("tableHeader", {}).get("cells", [])) - rows: list[tuple[str, ...]] = [] - for row in ex.get("tableBody", []): - rows.append(tuple(cell["value"] for cell in row["cells"])) - if not rows: - return None - return ExamplesTable(headers=headers, rows=tuple(rows)) +def _make_step( + *, + keyword: str, + text: str, + placeholders: list[Placeholder], + docstring: str | None, + data_table: DataTable | None, +) -> Step: + s = Step() + s.keyword = keyword + s.text = text + s.placeholders = placeholders + s.docstring = docstring + s.data_table = data_table + return s + + +def _make_examples(headers: list[str], rows: list[dict[str, str]]) -> Examples: + e = Examples() + e.headers = headers + e.rows = rows + return e -def _check_no_placeholders(steps: list[ParsedStep]) -> None: - for step in steps: - if step.placeholders: - raise GherkinError( - f"Background step '{step.text}' contains placeholder " - f"'<{step.placeholders[0].name}>'. " - f"Background steps must contain no placeholders." - ) +def _make_background(steps: list[Step]) -> Background: + b = Background() + b.steps = steps + return b -def _collect_placeholders( - steps: list[ParsedStep], -) -> tuple[Placeholder, ...]: - all_ph: list[Placeholder] = [] + +def _make_scenario( + *, + title: str, + slug: str, + function_name: str, + tags: list[str], + keyword: str, + steps: list[Step], + examples: Examples | None, +) -> Scenario: + s = Scenario() + s.title = title + s.slug = slug + s.function_name = function_name + s.tags = tags + s.keyword = keyword + s.steps = steps + s.examples = examples + return s + + +def _make_rule( + *, + name: str, + tags: list[str], + background: Background | None, + children: list[Scenario], +) -> Rule: + r = Rule() + r.name = name + r.tags = tags + r.background = background + r.children = children + return r + + +def _make_feature( + *, + name: str, + tags: list[str], + background: Background | None, + children: list[Rule | Scenario], +) -> Feature: + f = Feature() + f.name = name + f.tags = tags + f.background = background + f.children = children + return f + + +def _placeholders_from(text: str) -> list[Placeholder]: seen: set[str] = set() - for step in steps: - for ph in step.placeholders: - if ph.name not in seen: - seen.add(ph.name) - all_ph.append(ph) - return tuple(all_ph) - - -def _collect_literals( - scenario_steps: list[ParsedStep], - bg_steps: list[ParsedStep], - check_numeric: bool, - check_string: bool, -) -> tuple[Literal, ...]: - literals: list[Literal] = [] - for step in scenario_steps: - literals.extend(step.literals) - for step in bg_steps: - for lit in step.literals: - if (isinstance(lit.value, str) and check_string) or ( - isinstance(lit.value, int) and check_numeric - ): - literals.append(lit) - return tuple(literals) - - -def _build_scenario( - sc: dict, - feature_title: str, - feature_path: str, - rule_path: str, - feature_bg: list[ParsedStep], - rule_bg: list[ParsedStep], - check_numeric: bool, - check_string: bool, - seen_fn: dict[str, str], - skip_title_validation: bool = False, -) -> ScenarioInfo: - title = sc["name"] - if not skip_title_validation: - _validate_title(title, "Scenario", f"Feature: {feature_title}") - - function_name = _derive_function_name(title) - if function_name in seen_fn: - raise GherkinError( - f"Scenario title '{title}' produces function name " - f"'{function_name}' which collides with scenario in " - f"feature '{seen_fn[function_name]}'" - ) - seen_fn[function_name] = feature_title + result: list[Placeholder] = [] + for match in _PLACEHOLDER_RE.finditer(text): + name = match.group(1) + if name in seen: + continue + seen.add(name) + result.append(_make_placeholder(name)) + return result - steps = [_parse_step(s) for s in sc.get("steps", [])] - merged = feature_bg + rule_bg + steps - examples = _parse_examples(sc.get("examples", [])) - is_outline = bool(examples) +def _data_table_from(data: dict[str, Any]) -> DataTable: + rows_raw = data.get("rows") or [] + cells = [[c["value"] for c in row.get("cells", [])] for row in rows_raw] + if not cells: + return _make_data_table(headers=None, rows=[]) + return _make_data_table(headers=cells[0], rows=cells[1:]) - if is_outline and not examples.rows: - raise GherkinError( - f"Scenario Outline '{title}' must have at least one " - f"Examples table with at least one data row" - ) - return ScenarioInfo( - title=title, - function_name=function_name, - steps=tuple(merged), - placeholders=_collect_placeholders(merged), - literals=_collect_literals( - steps, feature_bg + rule_bg, check_numeric, check_string - ), - examples=examples, - is_outline=is_outline, - feature_title=feature_title, - feature_path=feature_path, - rule_path=rule_path, - line=sc["location"]["line"], +def _step_from(data: dict[str, Any]) -> Step: + text = data["text"] + dt = data.get("dataTable") + doc_string = data.get("docString") + return _make_step( + keyword=data["keyword"].strip(), + text=text, + placeholders=_placeholders_from(text), + docstring=doc_string.get("content") if doc_string else None, + data_table=_data_table_from(dt) if dt else None, ) -def _collect_scenarios_from_children( - children: list[dict], - feature_title: str, - feature_path: str, - feature_bg: list[ParsedStep], - check_numeric: bool, - check_string: bool, - seen_fn: dict[str, str], - skip_title_validation: bool = False, -) -> list[ScenarioInfo]: - scenarios: list[ScenarioInfo] = [] - for child in children: - if "background" in child: - continue +def _background_from(data: dict[str, Any]) -> Background: + return _make_background(steps=[_step_from(s) for s in data.get("steps", [])]) - if "scenario" in child: - sc = _build_scenario( - sc=child["scenario"], - feature_title=feature_title, - feature_path=feature_path, - rule_path="default_test", - feature_bg=feature_bg, - rule_bg=[], - check_numeric=check_numeric, - check_string=check_string, - seen_fn=seen_fn, - skip_title_validation=skip_title_validation, - ) - scenarios.append(sc) - - if "rule" in child: - rule = child["rule"] - rule_title = rule["name"] - if not skip_title_validation: - _validate_title(rule_title, "Rule", f"Feature: {feature_title}") - rp = _derive_rule_path(rule_title) + "_test" - - rule_bg: list[ParsedStep] = [] - for rc in rule.get("children", []): - if "background" in rc: - bg_steps = [ - _parse_step(s) for s in rc["background"].get("steps", []) - ] - _check_no_placeholders(bg_steps) - rule_bg = bg_steps - break - - for rc in rule.get("children", []): - if "scenario" in rc: - sc = _build_scenario( - sc=rc["scenario"], - feature_title=feature_title, - feature_path=feature_path, - rule_path=rp, - feature_bg=feature_bg, - rule_bg=rule_bg, - check_numeric=check_numeric, - check_string=check_string, - seen_fn=seen_fn, - skip_title_validation=skip_title_validation, - ) - scenarios.append(sc) - - return scenarios - - -def parse_feature( - feature_path: Path, - config: Config, - seen_function_names: dict[str, str] | None = None, - skip_title_validation: bool = False, -) -> dict[str, ScenarioInfo]: - """Parse a single .feature file into a dict of ScenarioInfo objects. - - Extracts steps, placeholders, literals, background steps, and examples - tables from every scenario and rule-scoped scenario. Title validation - runs by default but can be skipped for the inner parse pass used by - ``check_all`` (which already calls ``validate_all_titles`` globally). - - Args: - feature_path: Path to the ``.feature`` file. - config: The project configuration. - seen_function_names: Accumulator that tracks function-name collisions - across multiple parse calls. - skip_title_validation: When ``True``, per-title validation and duplicate - detection inside this parse call are suppressed. - - Returns: - A dict mapping function names to ``ScenarioInfo``. - - Raises: - GherkinError: If the file does not exist, cannot be parsed, or contains - no ``Feature:`` header. - - """ - if not feature_path.exists(): - raise GherkinError(f"Feature file not found: {feature_path}") - - try: - content = feature_path.read_text(encoding="utf-8") - doc = Parser().parse(content) - except Exception as e: - line = getattr(e, "line", 0) or 0 - raise GherkinError(f"{feature_path}:{line}: {e}") from e - - feature = doc.get("feature") - if not feature: - raise GherkinError(f"No feature found in {feature_path}") - - feature_title = feature["name"] - if not skip_title_validation: - _validate_title(feature_title, "Feature") - feature_path_str = _derive_feature_path(feature_title) - - if seen_function_names is None: - seen_function_names = {} - - feature_bg: list[ParsedStep] = [] - children = feature.get("children", []) - - for child in children: - if "background" in child: - bg_steps = [_parse_step(s) for s in child["background"].get("steps", [])] - _check_no_placeholders(bg_steps) - feature_bg = bg_steps - break - scenarios = _collect_scenarios_from_children( - children=children, - feature_title=feature_title, - feature_path=feature_path_str, - feature_bg=feature_bg, - check_numeric=config.background_check_numeric, - check_string=config.background_check_string, - seen_fn=seen_function_names, - skip_title_validation=skip_title_validation, - ) +def _examples_from(ex_list: list[dict[str, Any]]) -> Examples | None: + if not ex_list: + return None + ex = ex_list[0] + header_row = ex.get("tableHeader") or {} + headers = [c["value"] for c in header_row.get("cells", [])] + rows: list[dict[str, str]] = [] + for row in ex.get("tableBody", []): + cells = [c["value"] for c in row.get("cells", [])] + rows.append(dict(zip(headers, cells, strict=False))) + return _make_examples(headers=headers, rows=rows) - return {s.function_name: s for s in scenarios} +def _slug_from(title: str) -> str: + return _WHITESPACE_RE.sub("_", title.strip()).lower() -def _validate_single_title( - title: str, - kind: str, - path: str, - line: int, - violations: list[Violation], -) -> bool: - """Validate one title against the project rules. - - Rules enforced: - 1. Title must be non-empty after stripping. - 2. Title must match ``_TITLE_RE`` (Unicode letters, digits, spaces only). - 3. Title must contain 2-6 words. - - Args: - title: The raw title string from the feature file. - kind: One of ``"feature"``, ``"rule"``, ``"scenario"``. - path: The feature file path (for error reporting). - line: The source line number (for error reporting). - violations: Mutable list that receives ``Violation`` objects for - every rule that fails. - - Returns: - ``True`` if the title passes all checks, ``False`` otherwise. - - """ - error_type = f"invalid-{kind}-title" +def _validate_single_title(title: str, kind: str) -> None: if not title or not title.strip(): - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=f"{kind.capitalize()} title must be non-empty.", + raise ValueError(f"{kind} title must be non-empty") + for ch in title: + if not (ch.isspace() or ch.isalpha() or ch.isdigit()): + raise ValueError( + f"{kind} title {title!r} contains invalid character {ch!r}; " + f"only Unicode letters, digits, and spaces are allowed" ) - ) - return False - - if not _TITLE_RE.match(title): - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=( - f"{kind.capitalize()} title '{title}' contains " - f"invalid characters. Only Unicode letters, digits, " - f"and spaces are allowed." - ), - ) - ) - return False - words = title.split() - if len(words) < 2: - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=( - f"{kind.capitalize()} title '{title}' has " - f"{len(words)} word(s). Minimum 2 words required." - ), - ) + if len(words) < _MIN_WORD_COUNT or len(words) > _MAX_WORD_COUNT: + raise ValueError( + f"{kind} title {title!r} has {len(words)} word(s); " + f"must be {_MIN_WORD_COUNT}-{_MAX_WORD_COUNT}" ) - return False - if len(words) > 6: - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=( - f"{kind.capitalize()} title '{title}' has " - f"{len(words)} words. Maximum 6 words allowed." - ), + + +def _reject_duplicate(title: str, kind: str, seen: set[str]) -> None: + key = title.strip().lower() + if key in seen: + raise ValueError(f"Duplicate {kind} title {title!r} (case-insensitive match)") + seen.add(key) + + +def _reject_background_placeholders(background: Background) -> None: + for step in background.steps: + if step.placeholders: + raise ValueError( + f"Background step {step.text!r} contains placeholder " + f"<{step.placeholders[0].name}>; " + f"background steps must be placeholder-free" ) - ) - return False - return True + +def _first_background_from(children: list[dict[str, Any]]) -> Background | None: + for child in children: + if "background" in child: + bg = _background_from(child["background"]) + _reject_background_placeholders(bg) + return bg + return None + + +def _scenario_from( + data: dict[str, Any], + merged_steps: list[Step], + seen: set[str], +) -> Scenario: + title = data["name"] + _validate_single_title(title, "scenario") + _reject_duplicate(title, "scenario", seen) + slug = _slug_from(title) + own_steps = [_step_from(s) for s in data.get("steps", [])] + return _make_scenario( + title=title, + slug=slug, + function_name=f"test_{slug}", + tags=[t["name"] for t in data.get("tags", [])], + keyword=data["keyword"], + steps=[*merged_steps, *own_steps], + examples=_examples_from(data.get("examples", [])), + ) -def _register_title( - title: str, - kind: str, - path: str, - line: int, - seen: dict[str, list[tuple[str, str, str, int]]], -) -> None: - """Record a validated title in the case-insensitive duplicate tracker. - - Titles are normalised to lower case for comparison so ``"Hive Activity"`` - and ``"hive activity"`` map to the same key. - - Args: - title: The validated title string. - kind: One of ``"feature"``, ``"rule"``, ``"scenario"``. - path: The feature file path. - line: The source line number. - seen: Mutable dict that accumulates title entries keyed by lower-cased - title. - - """ - key = title.strip().lower() - seen.setdefault(key, []).append((title.strip(), kind, path, line)) - - -def _emit_duplicates( - seen: dict[str, list[tuple[str, str, str, int]]], - violations: list[Violation], -) -> None: - """Emit duplicate-title violations from the accumulated registry. - - When the same title key is used by more than one entity, a violation is - reported for the *lowest-priority* kind (scenario preferred over rule, - rule over feature) so that one logical "owner" is considered the - original and the others are flagged as duplicates. - - Args: - seen: Accumulator dict from ``_register_title``. - violations: Mutable list that receives ``Violation`` objects. - - """ - kind_priority = {"scenario": 0, "rule": 1, "feature": 2} - - for entries in seen.values(): - if len(entries) > 1: - kinds = {kind for _, kind, _, _ in entries} - report_kind = min(kinds, key=lambda k: kind_priority[k]) - - for title, kind, path, line in entries: - if kind != report_kind: - continue - violations.append( - Violation( - path=path, - line=line, - error_type=f"duplicate-{kind}-title", - message=( - f"Duplicate {kind} title '{title}' " - f"(case-insensitive match with " - f"'{entries[0][0]}')." - ), - ) +def _rule_from( + data: dict[str, Any], + feature_bg_steps: list[Step], + seen: set[str], +) -> Rule: + name = data["name"] + _reject_duplicate(name, "rule", seen) + rule_bg = _first_background_from(data.get("children", [])) + rule_bg_steps = rule_bg.steps if rule_bg else [] + children: list[Scenario] = [] + for rc in data.get("children", []): + if "scenario" in rc: + children.append( + _scenario_from( + rc["scenario"], + [*feature_bg_steps, *rule_bg_steps], + seen, ) + ) + return _make_rule( + name=name, + tags=[t["name"] for t in data.get("tags", [])], + background=rule_bg, + children=children, + ) -def detect_empty_rules(doc: dict) -> bool: - """Return True if the feature document has any rules with no scenarios. - - A rule is "empty" when it has no scenario children. Features with no - rules at all return ``False`` — only rules that exist but lack scenarios - are considered empty. - - Args: - doc: The parsed feature document dict from ``Parser().parse()``. - - Returns: - ``True`` if at least one rule exists with zero scenario children. - - """ - feature = doc.get("feature") - if not feature: - return False - for child in feature.get("children", []): - if "rule" in child: - rule = child["rule"] - rule_children = rule.get("children", []) - if not any("scenario" in rc for rc in rule_children): - return True - return False - - -def validate_all_titles(config: Config) -> list[Violation]: - """Validate all titles across every ``.feature`` file in the project. - - Scans the features directory, extracts every Feature, Rule, and Scenario - title, and checks: - - Character-set validity (``_TITLE_RE``). - - Non-empty requirement. - - Word-count bounds (2-6 words). - - Case-insensitive uniqueness across the whole project. - - Used as a pre-flight gate in ``generate_stubs`` and as the final step in - ``check_all``. - - Args: - config: The project configuration. - - Returns: - A (possibly empty) list of ``Violation`` objects. Each violation - carries an ``error_type`` of ``invalid-{kind}-title`` or - ``duplicate-{kind}-title``. - - """ - features_dir = Path(config.features_dir) - if not features_dir.is_dir(): - return [] - - violations: list[Violation] = [] - seen: dict[str, list[tuple[str, str, str, int]]] = {} - - for feature_path in sorted(features_dir.rglob("*.feature")): - try: - content = feature_path.read_text(encoding="utf-8") - doc = Parser().parse(content) - except Exception as e: - line = getattr(e, "line", 0) or 0 - raise GherkinError(f"{feature_path}:{line}: {e}") from e - - feature = doc.get("feature") - if not feature: - raise GherkinError(f"No feature found in {feature_path}") - - feature_title = feature["name"] - feature_line = feature.get("location", {}).get("line", 1) - if _validate_single_title( - feature_title, "feature", str(feature_path), feature_line, violations - ): - _register_title( - feature_title, "feature", str(feature_path), feature_line, seen - ) +def parse_feature(source: str) -> Feature: + doc = cast(dict[str, Any], Parser().parse(source)) + feature_data = cast(dict[str, Any], doc.get("feature") or {}) - for child in feature.get("children", []): - if "rule" in child: - rule = child["rule"] - rule_title = rule["name"] - rule_line = rule.get("location", {}).get("line", 1) - if _validate_single_title( - rule_title, "rule", str(feature_path), rule_line, violations - ): - _register_title( - rule_title, "rule", str(feature_path), rule_line, seen - ) - for rc in rule.get("children", []): - if "scenario" in rc: - sc = rc["scenario"] - sc_title = sc["name"] - sc_line = sc.get("location", {}).get("line", 1) - if _validate_single_title( - sc_title, - "scenario", - str(feature_path), - sc_line, - violations, - ): - _register_title( - sc_title, - "scenario", - str(feature_path), - sc_line, - seen, - ) - elif "scenario" in child: - sc = child["scenario"] - sc_title = sc["name"] - sc_line = sc.get("location", {}).get("line", 1) - if _validate_single_title( - sc_title, "scenario", str(feature_path), sc_line, violations - ): - _register_title( - sc_title, "scenario", str(feature_path), sc_line, seen - ) - - _emit_duplicates(seen, violations) - return violations + background = _first_background_from(feature_data.get("children", [])) + feature_bg_steps = background.steps if background else [] + + # Title rules enforce at parse time on every scenario title (charset + + # 2-6 word count + case-insensitive uniqueness). Feature and Rule names + # join the case-insensitive uniqueness set (data-model §2.1/§2.2 carry + # only a non-empty constraint, so charset/word-count do not apply to + # them — `Feature: Parsing` and `Feature: T` in the test fixtures are + # both one word). Background placeholder rejection is independent. + seen: set[str] = set() + feature_name = feature_data.get("name", "") + if feature_name: + seen.add(feature_name.strip().lower()) + + children: list[Rule | Scenario] = [] + for child in feature_data.get("children", []): + if "scenario" in child: + children.append(_scenario_from(child["scenario"], feature_bg_steps, seen)) + elif "rule" in child: + children.append(_rule_from(child["rule"], feature_bg_steps, seen)) + + return _make_feature( + name=feature_name, + tags=[t["name"] for t in feature_data.get("tags", [])], + background=background, + children=children, + ) diff --git a/beehave/models.py b/beehave/models.py deleted file mode 100644 index 73a8870..0000000 --- a/beehave/models.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Placeholder: - name: str - - -@dataclass(frozen=True) -class Literal: - value: str | int | float | bool - raw: str - - -@dataclass(frozen=True) -class ParsedStep: - keyword: str - text: str - placeholders: tuple[Placeholder, ...] = () - literals: tuple[Literal, ...] = () - line: int = 0 - - -@dataclass(frozen=True) -class ExamplesTable: - headers: tuple[str, ...] - rows: tuple[tuple[str, ...], ...] - - -@dataclass(frozen=True) -class ScenarioInfo: - title: str - function_name: str - steps: tuple[ParsedStep, ...] - placeholders: tuple[Placeholder, ...] - literals: tuple[Literal, ...] - examples: ExamplesTable | None - is_outline: bool - feature_title: str - feature_path: str - rule_path: str - line: int = 0 - - -@dataclass(frozen=True) -class TestInfo: - function_name: str - given_kwargs: tuple[str, ...] = () - example_rows: tuple[dict[str, object], ...] = () - body_name_nodes: tuple[str, ...] = () - body_constant_nodes: tuple[object, ...] = () - is_stub: bool = False - line: int = 0 - - -@dataclass(frozen=True) -class Violation: - path: str - line: int - error_type: str - message: str - is_warning: bool = False - - def __str__(self) -> str: - prefix = "warning" if self.is_warning else "error" - return f"{self.path}:{self.line}: {self.error_type}: {self.message} ({prefix})" - - -def coerce_example_value(cell: str) -> object: - if re.match(r"^-?\d+$", cell): - return int(cell) - if re.match(r"^-?\d+\.\d+$", cell): - return float(cell) - if cell.lower() == "true": - return True - if cell.lower() == "false": - return False - if cell.startswith('"') and cell.endswith('"'): - return cell[1:-1] - return cell diff --git a/tests/features/case_insensitive_matching/__init__.py b/beehave/py.typed similarity index 100% rename from tests/features/case_insensitive_matching/__init__.py rename to beehave/py.typed diff --git a/beehave/status.py b/beehave/status.py index 31ff762..b6c7aa2 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -1,394 +1,13 @@ from pathlib import Path -from gherkin import Parser -from beehave.check import check_pair -from beehave.config import Config -from beehave.discover import DiscoverError, discover_tests -from beehave.gherkin import detect_empty_rules, parse_feature - - -def compute_status( - config: Config, include_unmapped: bool = False, json_output: bool = False -) -> None: - features_dir = Path(config.features_dir) +def status(root: Path) -> int: + features_dir = root / "docs" / "features" if not features_dir.is_dir(): - if json_output: - import json as _json - - print( - _json.dumps( - {"error": f"features directory '{config.features_dir}' not found"} - ) - ) - else: - print( - f"Error: features directory '{config.features_dir}' not found", - file=__import__("sys").stderr, - ) - raise SystemExit(2) - - features = sorted(features_dir.rglob("*.feature")) - feature_slugs = {f.stem for f in features} - any_not_ok = False - - # Track function names across features for collision detection - all_fn_sources: dict[str, list[str]] = {} - - # JSON data collection - feature_data: list[dict] = [] - stage_counts: dict[str, int] = { - "ok": 0, - "broken": 0, - "no_scenarios": 0, - "needs_scenarios": 0, - "needs_tests": 0, - "needs_bodies": 0, - "needs_fixes": 0, - } - - def _emit(*args, **kwargs): - """Print or discard based on json_output mode.""" - if not json_output: - print(*args, **kwargs) - - first_feature = True - for feature_file in features: - slug = feature_file.stem - content = feature_file.read_text(encoding="utf-8") - try: - doc = Parser().parse(content) - except Exception as e: - any_not_ok = True - line = getattr(e, "line", 0) or 0 - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} (Bad Scenario) broken") - _emit(f" {feature_file}:{line}: {e}") - if json_output: - feature_data.append( - { - "slug": slug, - "title": "Bad Scenario", - "stage": "broken", - "parse_error_message": str(e), - "scenarios": [], - } - ) - stage_counts["broken"] += 1 - continue - - feature_title = doc["feature"]["name"] - scenarios = parse_feature(feature_file, config) - - if not scenarios and not detect_empty_rules(doc): - any_not_ok = True - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} ({feature_title}) no scenarios") - if json_output: - feature_data.append( - { - "slug": slug, - "title": feature_title, - "stage": "no scenarios", - "scenarios": [], - } - ) - stage_counts["no_scenarios"] += 1 - continue - elif not scenarios and detect_empty_rules(doc): - any_not_ok = True - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} ({feature_title}) needs scenarios") - if json_output: - feature_data.append( - { - "slug": slug, - "title": feature_title, - "stage": "needs scenarios", - "scenarios": [], - } - ) - stage_counts["needs_scenarios"] += 1 - continue - - rule_paths = {si.rule_path for si in scenarios.values()} - feature_path = next(iter(scenarios.values())).feature_path - test_dir = Path(config.tests_dir) / feature_path - - # Extract rule titles from the Gherkin doc - rule_titles: dict[str, str] = {} - for child in doc["feature"].get("children", []): - if "rule" in child: - rule_title = child["rule"]["name"] - slug_key = rule_title.strip().lower().replace(" ", "_") - rule_titles[slug_key + "_test"] = rule_title - - # Collect scenario-level statuses and violations - scenario_statuses: dict[str, str] = {} - scenario_violations: dict[str, list] = {} - for rp in rule_paths: - test_file = test_dir / f"{rp}.py" - tests: dict = {} - if test_file.exists(): - try: - tests = discover_tests(test_file) - except DiscoverError: - tests = {} - for fn in tests: - all_fn_sources.setdefault(fn, []).append(slug) - for fn, si in scenarios.items(): - if si.rule_path != rp: - continue - ti = tests.get(fn) - if ti is None: - scenario_statuses[fn] = "no test" - elif ti.is_stub: - scenario_statuses[fn] = "no body" - else: - violations = check_pair(si, ti, str(test_file), feature_file.name) - if violations: - scenario_violations[fn] = violations - scenario_statuses[fn] = f"{len(violations)} errors" - else: - scenario_statuses[fn] = "ok" - - unmapped_count = sum(1 for s in scenario_statuses.values() if s == "no test") - all_stubs = all(s == "no body" for s in scenario_statuses.values()) - has_violation = any("errors" in s for s in scenario_statuses.values()) - - if unmapped_count > 0: - status_label = "needs tests" - elif all_stubs: - status_label = "needs bodies" - elif has_violation: - status_label = "needs fixes" - else: - status_label = "ok" - - if status_label != "ok": - any_not_ok = True - - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} ({feature_title}) {status_label}") - - # Tree output for non-ok features (text mode only) - if status_label != "ok" and not json_output: - _print_tree( - scenarios, - scenario_statuses, - scenario_violations, - rule_paths, - rule_titles, - ) - - if json_output: - sc_list: list[dict] = [] - for fn, si in scenarios.items(): - sc_status = scenario_statuses.get(fn, "unknown") - entry = { - "title": si.title, - "function_name": fn, - "status": sc_status, - } - if fn in scenario_violations: - entry["violations"] = [ - {"type": v.error_type, "message": v.message} - for v in scenario_violations[fn] - ] - sc_list.append(entry) - - feature_data.append( - { - "slug": slug, - "title": feature_title, - "stage": status_label, - "scenarios": sc_list, - } - ) - stage_key = status_label.replace(" ", "_") - stage_counts[stage_key] = stage_counts.get(stage_key, 0) + 1 - - # Report unmapped test directories if flagged - unmapped_dirs: list[str] = [] - if include_unmapped: - tests_dir = Path(config.tests_dir) - if tests_dir.is_dir(): - for test_subdir in sorted(tests_dir.iterdir()): - if test_subdir.is_dir() and test_subdir.name not in feature_slugs: - unmapped_dirs.append(test_subdir.name) - - # Report collisions - collisions = {fn: slugs for fn, slugs in all_fn_sources.items() if len(slugs) > 1} - - if json_output: - result = { - "features": feature_data, - "summary": { - "total_features": len(feature_data), - **{ - k: stage_counts.get(k, 0) - for k in [ - "ok", - "broken", - "no_scenarios", - "needs_scenarios", - "needs_tests", - "needs_bodies", - "needs_fixes", - ] - }, - }, - "unmapped_directories": unmapped_dirs, - "collisions": [ - {"function_name": fn, "feature_slugs": slugs} - for fn, slugs in sorted(collisions.items()) - ], - } - import json as _json - - print(_json.dumps(result)) - else: - if unmapped_dirs: - if not first_feature: - print() - for name in unmapped_dirs: - print(f"unmapped: {name}") - - if collisions: - if not first_feature or unmapped_dirs: - print() - for fn, slugs in sorted(collisions.items()): - print(f"collision: {fn} appears in {', '.join(slugs)}") - - raise SystemExit(1 if any_not_ok else 0) - - -def _rule_aggregation(rule_statuses: dict[str, str]) -> str: - """Build comma-joined aggregation string for a rule's scenario statuses.""" - counts: dict[str, int] = {} - total_errors = 0 - for status in rule_statuses.values(): - if status == "ok": - continue - if "errors" in status: - parts = status.split() - try: - total_errors += int(parts[0]) - except ValueError, IndexError: - counts[status] = counts.get(status, 0) + 1 - else: - counts[status] = counts.get(status, 0) + 1 - - parts: list[str] = [] - for status in sorted(counts): - parts.append(f"{counts[status]} {status}") - if total_errors > 0: - word = "error" if total_errors == 1 else "errors" - parts.append(f"{total_errors} {word}") - return ", ".join(parts) - - -def _print_tree( - scenarios: dict, - scenario_statuses: dict[str, str], - scenario_violations: dict[str, list], - rule_paths: set[str], - rule_titles: dict[str, str], -) -> None: - # Group scenarios by rule_path - by_rule: dict[str, dict[str, str]] = {} - for fn, si in scenarios.items(): - by_rule.setdefault(si.rule_path, {})[fn] = scenario_statuses[fn] - - sorted_rules = sorted(by_rule.keys()) - - # If all scenarios are under "default_test", just show them directly - if sorted_rules == ["default_test"]: - scenarios_in_rule = by_rule["default_test"] - sorted_fns = sorted(scenarios_in_rule.keys()) - for idx, fn in enumerate(sorted_fns): - sn = scenarios[fn] - status = scenario_statuses[fn] - extra = _violation_codes(scenario_violations.get(fn)) - is_last = idx == len(sorted_fns) - 1 - prefix = " └──" if is_last else " ├──" - line = f"{prefix} Scenario: {sn.title} {status}" - if extra: - line += f" {extra}" - print(line) - return - - # Multiple rules or single non-default rule: show rule nodes - for rule_idx, rp in enumerate(sorted_rules): - scenarios_in_rule = by_rule[rp] - - # Get rule title from doc or derive from rule_path - rule_title = rule_titles.get(rp) - if rule_title is None: - if rp.endswith("_test"): - rule_title = rp[:-5].replace("_", " ").title() - else: - rule_title = rp.replace("_", " ").title() - - # Build aggregation for this rule - rule_statuses: dict[str, str] = {} - for fn in scenarios_in_rule: - if fn in scenario_statuses: - rule_statuses[fn] = scenario_statuses[fn] - - agg = _rule_aggregation(rule_statuses) - is_last_rule = rule_idx == len(sorted_rules) - 1 - rule_prefix = " └──" if is_last_rule else " ├──" - - if agg: - print(f"{rule_prefix} Rule: {rule_title} ({agg})") - else: - print(f"{rule_prefix} Rule: {rule_title}") - - sorted_fns = sorted(scenarios_in_rule.keys()) - for sc_idx, fn in enumerate(sorted_fns): - sn = scenarios[fn] - status = scenario_statuses[fn] - extra = _violation_codes(scenario_violations.get(fn)) - is_last_sc = sc_idx == len(sorted_fns) - 1 - - if is_last_rule: - sc_prefix = " " + ("└──" if is_last_sc else "├──") - else: - sc_prefix = " │ " + ("└──" if is_last_sc else "├──") - - line = f"{sc_prefix} Scenario: {sn.title} {status}" - if extra: - line += f" {extra}" - print(line) - - -def _violation_codes(violations: list | None) -> str: - """Extract comma-joined violation identifiers for inline display.""" - if not violations: - return "" - codes: list[str] = [] - for v in violations: - error_type = v.error_type - if error_type == "missing-placeholder": - # Message format: "'name' not found in function body" - msg = v.message - if "'" in msg: - name = msg.split("'")[1] - codes.append(name) - elif error_type == "missing-literal": - # Message format: "literal 'value' not found..." - msg = v.message - if msg.startswith("literal "): - rest = msg[len("literal ") :] - name = rest.split()[0].strip("'\"") - codes.append(name) - return ", ".join(codes) + return 2 + feature_count = len(list(features_dir.glob("*.feature"))) + tests_dir = root / "tests" + stub_count = len(list(tests_dir.glob("*_test.pyi"))) if tests_dir.is_dir() else 0 + print(f"{feature_count} feature file(s)") + print(f"{stub_count} stub file(s)") + return 0 diff --git a/beehave/step.py b/beehave/step.py new file mode 100644 index 0000000..826204e --- /dev/null +++ b/beehave/step.py @@ -0,0 +1,16 @@ +from collections.abc import Iterator +from contextlib import contextmanager + + +@contextmanager +def step( + keyword: str, + text: str, + /, + **placeholders: object, +) -> Iterator[None]: + try: + yield + except Exception as e: + e.add_note(f"{keyword} {text}") + raise diff --git a/pyproject.toml b/pyproject.toml index d2aaf91..29c0022 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "1.0.0" +version = "2.0.0" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" @@ -25,13 +25,14 @@ dev = [ "beehave", "flowr[viz]>=1.1.0", "hypothesis>=6.152.7", + "mypy>=2.3.0", "pytest>=8.0", - "pytest-beehave>=0.2.2", "ruff>=0.11", + "taskipy>=1.14.1", ] [tool.ruff.lint] -select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"] +select = ["E", "F", "W", "I", "N", "UP", "B"] preview = true [tool.ruff.lint.per-file-ignores] @@ -44,3 +45,14 @@ python_functions = ["test_*"] [tool.uv.sources] beehave = { workspace = true } + +[tool.mypy] +python_version = "3.14" +strict = true + +[tool.taskipy.tasks] +test = "pytest" +test-fast = "pytest -x -q" +lint = "ruff check ." +lint-merge = "ruff check --select E,F,W,I,N,UP,B,SIM,RUF --preview . && ruff format --check ." +stubtest = "python -m mypy.stubtest beehave --allowlist .stubtest_allowlist" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 2c2d487..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import os -import textwrap -from collections.abc import Generator -from pathlib import Path - -import pytest - -from beehave.config import Config - - -@pytest.fixture -def tmp_project(tmp_path: Path) -> Generator[Path]: - features = tmp_path / "docs" / "features" - tests = tmp_path / "tests" / "features" - features.mkdir(parents=True) - tests.mkdir(parents=True) - old = os.getcwd() - os.chdir(tmp_path) - yield tmp_path - os.chdir(old) - - -@pytest.fixture -def config(tmp_project: Path) -> Config: - return Config( - features_dir=str(tmp_project / "docs" / "features"), - tests_dir=str(tmp_project / "tests" / "features"), - ) - - -def write_feature( - tmp_project: Path, - name: str, - content: str, -) -> Path: - features_dir = tmp_project / "docs" / "features" - parts = name.rsplit("/", 1) - if len(parts) == 2: - features_dir = features_dir / parts[0] - features_dir.mkdir(parents=True, exist_ok=True) - fname = parts[1] - else: - fname = parts[0] - p = features_dir / f"{fname}.feature" - p.write_text(textwrap.dedent(content), encoding="utf-8") - return p - - -def write_test( - tmp_project: Path, - feature_dir: str, - filename: str, - source: str, -) -> Path: - d = tmp_project / "tests" / "features" / feature_dir - d.mkdir(parents=True, exist_ok=True) - p = d / filename - p.write_text(textwrap.dedent(source), encoding="utf-8") - return p diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index 97e6514..ad49a7b 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -3,14 +3,12 @@ import shutil from pathlib import Path -import pytest - HIVE_ACTIVITY_FEATURE = "hive_activity.feature" COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" def copy_feature_into_pytester(pytester, basename: str) -> str: - src = Path("docs") / "features" / basename + src = Path(__file__).resolve().parents[2] / "docs" / "features" / basename dst = pytester.path / "docs" / "features" / basename dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy(src, dst) @@ -35,14 +33,12 @@ def run_beehave_check(pytester, *args: str) -> int: return pytester.run("beehave", "check", *args).ret -@pytest.mark.pending def test_passes_when_blocks_match_steps(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) pytester.run("beehave", "generate") assert run_beehave_check(pytester) == 0 -@pytest.mark.pending def test_fails_when_block_count_differs_from_step_count(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -62,7 +58,6 @@ def test_fails_when_block_count_differs_from_step_count(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -84,7 +79,6 @@ def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_fails_when_step_text_mismatches(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -106,7 +100,6 @@ def test_fails_when_step_text_mismatches(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -132,7 +125,6 @@ def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_passes_when_keyword_case_differs(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -154,7 +146,6 @@ def test_passes_when_keyword_case_differs(pytester) -> None: assert run_beehave_check(pytester) == 0 -@pytest.mark.pending def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -177,7 +168,6 @@ def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: assert run_beehave_check(pytester) == 0 -@pytest.mark.pending def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: feature_text = ( "Feature: Minimal\n" diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index e86075b..5523ed7 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -3,8 +3,6 @@ import shutil from pathlib import Path -import pytest - HIVE_ACTIVITY_FEATURE = "hive_activity.feature" COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" TITLE_VALIDATION_FEATURE = "title_validation.feature" @@ -16,7 +14,7 @@ def copy_feature_into_pytester(pytester, basename: str) -> str: - src = Path("docs") / "features" / basename + src = Path(__file__).resolve().parents[2] / "docs" / "features" / basename dst = pytester.path / "docs" / "features" / basename dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy(src, dst) @@ -60,7 +58,6 @@ def list_emitted_stems(pytester) -> list[str]: return sorted(stems) -@pytest.mark.pending def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -70,7 +67,6 @@ def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: assert "hive_activity_hive_foraging" in stems -@pytest.mark.pending def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) exit_code = run_beehave_generate(pytester) @@ -81,7 +77,6 @@ def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: assert read_emitted_pyi(pytester, stem) != "" -@pytest.mark.pending def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -91,7 +86,6 @@ def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: assert first_emission == second_emission -@pytest.mark.pending def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -99,7 +93,6 @@ def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: assert "def test_guard_bee_inspects_visitor" in pyi -@pytest.mark.pending def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) -> None: feature_text = ( "Feature: Whitespace\n" @@ -112,7 +105,6 @@ def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) - assert "def test_mixedcase_title_with_spaces" in pyi -@pytest.mark.pending def test_feature_background_steps_appear_in_every_emitted_scenario(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -125,7 +117,6 @@ def test_feature_background_steps_appear_in_every_emitted_scenario(pytester) -> assert background_text in foraging_py -@pytest.mark.pending def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -138,7 +129,6 @@ def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> N assert rule_background_text not in default_py -@pytest.mark.pending def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: feature_text = ( "@unique_tag_marker\n" @@ -154,7 +144,6 @@ def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: assert "unique_tag_marker" not in py_text -@pytest.mark.pending def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: feature_text = ( "Feature: Docstring\n" @@ -170,7 +159,6 @@ def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: assert "unique docstring marker text" not in pyi -@pytest.mark.pending def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: feature_text = ( "Feature: DataTable\n" diff --git a/tests/e2e/status_test.py b/tests/e2e/status_test.py index d9b1cd9..88ae30b 100644 --- a/tests/e2e/status_test.py +++ b/tests/e2e/status_test.py @@ -1,7 +1,5 @@ from __future__ import annotations -import pytest - def write_feature_text(pytester, basename: str, text: str) -> str: dst = pytester.path / "docs" / "features" / basename @@ -26,13 +24,11 @@ def status_stdout(pytester) -> str: return "\n".join(result.outlines) -@pytest.mark.pending def test_status_exits_zero_when_features_dir_exists(pytester) -> None: write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") assert run_beehave_status(pytester) == 0 -@pytest.mark.pending def test_status_reports_feature_file_count(pytester) -> None: write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") write_feature_text(pytester, "b.feature", "Feature: B\nScenario: bbbbb\nGiven x\n") @@ -41,7 +37,6 @@ def test_status_reports_feature_file_count(pytester) -> None: assert "feature" in stdout.lower() -@pytest.mark.pending def test_status_reports_emitted_stub_count(pytester) -> None: write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") write_pyi_stub(pytester, "a_default") @@ -51,6 +46,5 @@ def test_status_reports_emitted_stub_count(pytester) -> None: assert "stub" in stdout.lower() -@pytest.mark.pending def test_status_exits_two_when_features_dir_missing(pytester) -> None: assert run_beehave_status(pytester) == 2 diff --git a/tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py b/tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py deleted file mode 100644 index 038db3d..0000000 --- a/tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py +++ /dev/null @@ -1 +0,0 @@ -def test_bracket_notation_captured_verbatim(): ... diff --git a/tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py b/tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py deleted file mode 100644 index af098da..0000000 --- a/tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 - -from beehave.check import check_all -from beehave.config import Config - - -def test_string_literal_matches_lowercase_constant( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Literal Matching Case Insensitive - - Scenario: string literal matches lowercase constant - Given a dog named "Rex" - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "literal_matching_case_insensitive_test.py", - """\ - def test_string_literal_matches_lowercase_constant(): - "rex" - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations (case-insensitive match: " - f"'Rex' should match 'rex'), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -def test_string_literal_matches_uppercase_constant(): ... - - -def test_numeric_literal_matches_stringified_decimal(): ... diff --git a/tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py b/tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py deleted file mode 100644 index 5a9817a..0000000 --- a/tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 - -from beehave.check import check_all -from beehave.config import Config - - -def test_negative_integer_literal_matches_body_constant( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Negative Numbers Visible In Body - - Scenario: negative integer literal matches body constant - Given the balance is -2010 - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "negative_numbers_visible_in_body_test.py", - """\ - def test_negative_integer_literal_matches_body_constant(): - balance = -2010 - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations (-2010 from Gherkin should match " - f"-2010 in body), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -def test_negative_float_literal_matches_body_constant(): ... - - -def test_positive_integer_still_works(): ... diff --git a/tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py b/tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py deleted file mode 100644 index bbeb378..0000000 --- a/tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py +++ /dev/null @@ -1,59 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 -from hypothesis import HealthCheck, given, settings, strategies as st - -from beehave.check import check_all -from beehave.config import Config - - -@given(Dog=st.text()) -@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) -def test_placeholder_matches_lowercase_body_name( - Dog: str, tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Placeholder Matching Case Insensitive - - Scenario: placeholder matches lowercase body name - Given a barks - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "placeholder_matching_case_insensitive_test.py", - """\ - from hypothesis import given, strategies as st - - @given(Dog=st.text()) - def test_placeholder_matches_lowercase_body_name(Dog): - dog - """, - ) - - violations = check_all(config) - - mp_violations = [v for v in violations if v.error_type == "missing-placeholder"] - assert len(mp_violations) == 0, ( - f"expected 0 missing-placeholder violations (case-insensitive), " - f"got {len(mp_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -@given(Dog=st.text()) -def test_placeholder_matches_uppercase_body_name(Dog): ... - - -@given(Dog=st.text()) -def test_placeholder_matches_mixed_case_body_name(Dog): ... - - -@given(Dog=st.text()) -def test_placeholder_does_not_match_different_identifier(Dog): ... diff --git a/tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py b/tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py deleted file mode 100644 index 85d3c44..0000000 --- a/tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py +++ /dev/null @@ -1,86 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 - -from beehave.check import check_all -from beehave.config import Config - - -def test_quoted_placeholder_not_captured_as_literal( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Quoted Placeholder Not Double Captured - - Scenario: quoted placeholder not captured as literal - Given a dog named "" - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "quoted_placeholder_not_double_captured_test.py", - """\ - def test_quoted_placeholder_not_captured_as_literal(): - name - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations " - f"('' inside quotes should not be captured as a literal), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - mp_violations = [v for v in violations if v.error_type == "missing-placeholder"] - assert len(mp_violations) == 0, ( - f"expected 0 missing-placeholder violations " - f"(placeholder matched case-insensitively by body 'name'), " - f"got {len(mp_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -def test_non_placeholder_quoted_content_captured( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Quoted Placeholder Not Double Captured - - Scenario: non placeholder quoted content captured - Given a phone number "[PHONE]" - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "quoted_placeholder_not_double_captured_test.py", - """\ - def test_non_placeholder_quoted_content_captured(): - "[PHONE]" - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations " - f"('[PHONE]' inside quotes should be captured as a literal and " - f"match the body's '[PHONE]'), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) diff --git a/tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py b/tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py deleted file mode 100644 index 2fd09a4..0000000 --- a/tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py +++ /dev/null @@ -1,5 +0,0 @@ -from hypothesis import given, strategies as st - - -@given(Dog=st.text()) -def test_stub_test_produces_no_violations(Dog): ... diff --git a/tests/features/case_insensitive_matching/true_and_one_never_collide_test.py b/tests/features/case_insensitive_matching/true_and_one_never_collide_test.py deleted file mode 100644 index d2f541a..0000000 --- a/tests/features/case_insensitive_matching/true_and_one_never_collide_test.py +++ /dev/null @@ -1 +0,0 @@ -def test_integer_one_not_match_true_boolean(): ... diff --git a/tests/features/comb_construction/__init__.py b/tests/features/comb_construction/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/features/comb_construction/brood_management_test.py b/tests/features/comb_construction/brood_management_test.py deleted file mode 100644 index 249155a..0000000 --- a/tests/features/comb_construction/brood_management_test.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -from hypothesis import given, example, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(kind=st.text()) -def test_queen_lays_egg_in_cell(kind): ... - - -@pytest.mark.skip(reason="not implemented") -@example(ambient=28, workers=20, target=35) -@example(ambient=40, workers=15, target=35) -@given(ambient=st.integers(), workers=st.integers(), target=st.integers()) -def test_nursery_temperature_regulation(ambient, workers, target): ... diff --git a/tests/features/comb_construction/comb_building_test.py b/tests/features/comb_construction/comb_building_test.py deleted file mode 100644 index 50692af..0000000 --- a/tests/features/comb_construction/comb_building_test.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(amount=st.text()) -def test_worker_builds_a_hexagonal_cell(amount): ... diff --git a/tests/features/hive_activity/__init__.py b/tests/features/hive_activity/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/features/hive_activity/default_test.py b/tests/features/hive_activity/default_test.py deleted file mode 100644 index c669a6f..0000000 --- a/tests/features/hive_activity/default_test.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -from hypothesis import given, example, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@example(nectar=100, rate=20, hours=8, honey=80) -@example(nectar=200, rate=25, hours=12, honey=150) -@example(nectar=50, rate=30, hours=6, honey=35) -@given( - nectar=st.integers(), rate=st.integers(), hours=st.integers(), honey=st.integers() -) -def test_honey_production_from_nectar(nectar, rate, hours, honey): ... diff --git a/tests/features/hive_activity/foraging_test.py b/tests/features/hive_activity/foraging_test.py deleted file mode 100644 index 170fe8d..0000000 --- a/tests/features/hive_activity/foraging_test.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(name=st.text(), volume=st.text()) -def test_forager_returns_with_nectar(name, volume): ... diff --git a/tests/features/hive_activity/hive_defense_test.py b/tests/features/hive_activity/hive_defense_test.py deleted file mode 100644 index 2c95b0f..0000000 --- a/tests/features/hive_activity/hive_defense_test.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(scent=st.text(), outcome=st.text()) -def test_guard_bee_inspects_visitor(scent, outcome): ... diff --git a/tests/features/hive_activity/hive_foraging_test.py b/tests/features/hive_activity/hive_foraging_test.py deleted file mode 100644 index 494986d..0000000 --- a/tests/features/hive_activity/hive_foraging_test.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - -@pytest.mark.skip(reason="not implemented") -@given(name=st.text(), volume=st.text()) -def test_forager_returns_with_nectar(name, volume): - ... - diff --git a/tests/features/status_command/__init__.py b/tests/features/status_command/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/features/status_command/all_passing_derives_ok_test.py b/tests/features/status_command/all_passing_derives_ok_test.py deleted file mode 100644 index 8a3ad6c..0000000 --- a/tests/features/status_command/all_passing_derives_ok_test.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_with_all_scenarios_passing(tmp_project, config, capsys): - """Feature where all scenarios have non-stub tests with zero violations → 'ok'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 3 - _feature_ref = "docs/features/fully_implemented.feature" - - write_feature( - tmp_project, - "passing", - """\ - Feature: Passing Feature - Scenario: Login Succeeds - Given a user named "Alice" with password "secret" is registered - When the user logs in with "Alice" and "secret" - Then the user sees "Welcome" - - Scenario: Logout Succeeds - Given the user "Alice" is logged in - When the user clicks logout - Then the user sees "Goodbye" - """, - ) - # assert features_dir path exists - assert (tmp_project / features_dir).exists() - - write_test( - tmp_project, - "passing_feature", - "default_test.py", - """\ - def test_login_succeeds(): - assert "Alice" == "Alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - - def test_logout_succeeds(): - assert "Alice" == "Alice" - assert "Goodbye" == "Goodbye" - """, - ) - assert (tmp_project / tests_dir).exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "ok" in captured.out - # scenarios_ok is 3 - ok_count = 3 - assert ok_count == 3 - # scenarios_errors is 0 - error_count = 0 - assert error_count == 0 - assert n_scenarios == 3 diff --git a/tests/features/status_command/all_stubs_derive_stage_test.py b/tests/features/status_command/all_stubs_derive_stage_test.py deleted file mode 100644 index 316cea6..0000000 --- a/tests/features/status_command/all_stubs_derive_stage_test.py +++ /dev/null @@ -1,121 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_scenarios_all_mapped_to_stubs(tmp_project, config, capsys): - """Feature where all scenarios are mapped to stub tests → 'needs bodies'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 3 - stub_body = "..." - - write_feature( - tmp_project, - "stub_feature", - """\ - Feature: Stub Feature - Scenario: First Scenario - Given a thing - When action - Then result - - Scenario: Second Scenario - Given another thing - When action - Then result - """, - ) - # BDD step references "docs/features/stub_all.feature" — verify features dir - _feature_ref = "docs/features/stub_all.feature" - assert (tmp_project / "docs" / "features").exists() - - write_test( - tmp_project, - "stub_feature", - "default_test.py", - """\ - def test_first_scenario(): - pass - - def test_second_scenario(): - pass - """, - ) - # BDD step references "tests/features/stub_all/default_test.py" - _test_ref = "tests/features/stub_all/default_test.py" - assert (tmp_project / "tests" / "features").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs bodies" in captured.out - # all scenario statuses are "no body" - assert "no body" in captured.out - assert n_scenarios == 3 - no_body = 3 - assert no_body == 3 - ok_count = 0 - assert ok_count == 0 - # every matching test function body is "..." - assert stub_body == "..." - - -def test_feature_with_stub_and_non_stub(tmp_project, config, capsys): - """Feature where one scenario is non-stub ok, one is stub pass → 'needs bodies'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 2 - _feature_ref = "docs/features/stub_mix.feature" - - write_feature( - tmp_project, - "mixed", - """\ - Feature: Mixed Feature - Scenario: Simple Action - Given a step literal "hello" - When perform action - Then result is achieved - """, - ) - - # test "test_implemented" is non-stub with zero violations - _test_name = "test_implemented" - # test "test_not_implemented" is a stub with body "pass" - _stub_name = "test_not_implemented" - _pass_body = "pass" - - write_test( - tmp_project, - "mixed_feature", - "default_test.py", - """\ - def test_simple_action(): - assert "hello" == "hello" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - # scenario "test_implemented" status is "ok" - assert "ok" in captured.out - assert _test_name == "test_implemented" - # scenario "test_not_implemented" status is "no body" - assert _stub_name == "test_not_implemented" - # the feature stage is "needs bodies" (BDD literal for traceability) - _stage = "needs bodies" - # literal "no body" for traceability - _no_body = "no body" - # body "pass" for traceability - assert _pass_body == "pass" - assert n_scenarios == 2 diff --git a/tests/features/status_command/cross_feature_collisions_detected_test.py b/tests/features/status_command/cross_feature_collisions_detected_test.py deleted file mode 100644 index 70d44ce..0000000 --- a/tests/features/status_command/cross_feature_collisions_detected_test.py +++ /dev/null @@ -1,148 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_two_features_produce_same_function_name(tmp_project, config, capsys): - """Duplicate function names across features → collision reported.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - collision_scenario = "login" - auth_test_path = "tests/features/auth/default_test.py" - sso_test_path = "tests/features/sso/default_test.py" - - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth Feature - Scenario: Login - Given a login page - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "auth_feature", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - write_feature( - tmp_project, - "sso", - """\ - Feature: SSO Feature - Scenario: Login - Given an sso page - When the user logs in via sso - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "sso_feature", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - assert collision_scenario == "login" - assert auth_test_path == "tests/features/auth/default_test.py" - assert sso_test_path == "tests/features/sso/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - # Collision should be reported - assert "test_login" in captured.out - assert "collision" in captured.out - - -def test_no_collisions_unique_function_names(tmp_project, config, capsys): - """Unique function names → no collisions reported.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - auth_functions = ["test_login", "test_logout"] - payment_functions = ["test_charge", "test_refund"] - - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth Feature - Scenario: Login - Given a login page - When the user logs in - Then the user is authenticated - Scenario: Logout - Given a logged in user - When the user logs out - Then the user is logged out - """, - ) - write_test( - tmp_project, - "auth_feature", - "default_test.py", - """\ - def test_login(): - pass - - def test_logout(): - pass - """, - ) - - write_feature( - tmp_project, - "payment", - """\ - Feature: Payment Feature - Scenario: Charge - Given a payment method - When the charge occurs - Then the charge is successful - Scenario: Refund - Given a previous charge - When the refund occurs - Then the refund is successful - """, - ) - write_test( - tmp_project, - "payment_feature", - "default_test.py", - """\ - def test_charge(): - pass - - def test_refund(): - pass - """, - ) - - assert auth_functions[0] == "test_login" - assert auth_functions[1] == "test_logout" - assert payment_functions[0] == "test_charge" - assert payment_functions[1] == "test_refund" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "collision" not in captured.out diff --git a/tests/features/status_command/empty_features_report_no_scenarios_test.py b/tests/features/status_command/empty_features_report_no_scenarios_test.py deleted file mode 100644 index bfae7bc..0000000 --- a/tests/features/status_command/empty_features_report_no_scenarios_test.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature - - -def test_feature_with_title_only_and_comment(tmp_project, config, capsys): - """Feature with only a title and comment (no scenarios) reports 'no scenarios'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "placeholder", - """\ - Feature: Placeholder - # Work in progress — no scenarios yet - """, - ) - assert (tmp_project / "docs/features/placeholder.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "placeholder" in captured.out - assert "(Placeholder)" in captured.out - assert "no scenarios" in captured.out - # scenarios_total is 0, scenarios_ok is 0, scenarios_no_test is 0 - features_with_scenarios = [l for l in captured.out.split("\n") if "Scenario" in l] - total = len(features_with_scenarios) - assert total == 0 - ok_count = 0 - assert ok_count == 0 - no_test_count = 0 - assert no_test_count == 0 - - -def test_feature_with_background_but_no_scenarios(tmp_project, config, capsys): - """Feature with a Background but no Scenarios reports 'no scenarios'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "bg_only", - """\ - Feature: Background Only - Background: - Given the system is initialized - """, - ) - assert (tmp_project / "docs/features/bg_only.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "bg_only" in captured.out - assert "(Background Only)" in captured.out - assert "no scenarios" in captured.out diff --git a/tests/features/status_command/exit_codes_reflect_overall_status_test.py b/tests/features/status_command/exit_codes_reflect_overall_status_test.py deleted file mode 100644 index fcaf91e..0000000 --- a/tests/features/status_command/exit_codes_reflect_overall_status_test.py +++ /dev/null @@ -1,177 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_all_features_ok_exits_zero(tmp_project, config): - """All features ok → SystemExit with code 0.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 2 - ok_stage = "ok" - - write_feature( - tmp_project, - "login", - """\ - Feature: Login - Scenario: User Logs In - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user sees "Welcome" - """, - ) - write_test( - tmp_project, - "login", - "default_test.py", - """\ - def test_user_logs_in(): - assert "alice" == "alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - """, - ) - - write_feature( - tmp_project, - "signup", - """\ - Feature: Signup - Scenario: User Signs Up - Given a registration form - When the user submits with "bob" and "pass" - Then the user is registered - """, - ) - write_test( - tmp_project, - "signup", - "default_test.py", - """\ - def test_user_signs_up(): - assert "bob" == "bob" - assert "pass" == "pass" - """, - ) - - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 0 - assert n_features == 2 - assert ok_stage == "ok" - - -def test_any_feature_not_ok_exits_one(tmp_project, config): - """Any feature not ok → SystemExit with code 1.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 2 - ok_stage = "ok" - needs_tests_stage = "needs tests" - - write_feature( - tmp_project, - "login", - """\ - Feature: Login - Scenario: User Logs In - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user sees "Welcome" - """, - ) - write_test( - tmp_project, - "login", - "default_test.py", - """\ - def test_user_logs_in(): - assert "alice" == "alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - """, - ) - - write_feature( - tmp_project, - "signup", - """\ - Feature: Signup - Scenario: User Signs Up - Given a registration form - When the user submits with "bob" and "pass" - Then the user is registered - """, - ) - # No test file for signup → needs tests - - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 1 - assert n_features == 2 - assert ok_stage == "ok" - assert needs_tests_stage == "needs tests" - - -def test_broken_feature_exits_with_code_one(tmp_project, config): - """Broken feature → SystemExit with code 1.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - parse_error_msg = "No feature found" - broken_stage = "broken" - - write_feature( - tmp_project, - "broken_feature", - """\ - Feature: Broken - Scenario missing colon - Given something - """, - ) - assert parse_error_msg == "No feature found" - assert broken_stage == "broken" - - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 1 - - -def test_features_directory_missing_exits_two(config): - """Missing features directory → SystemExit with code 2.""" - from pathlib import Path - from beehave.config import Config - - features_dir = "docs/features" - config = Config( - features_dir="nonexistent_dir", - tests_dir="tests/features", - ) - _check_val = features_dir == "docs/features" - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 2 - - -def test_project_no_feature_files_exits_zero(tmp_project, config): - """Zero feature files → SystemExit with code 0.""" - features_dir = "docs/features" - tests_dir = "tests/features" - # features directory "docs/features" exists but contains zero .feature files - assert features_dir == "docs/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 0 diff --git a/tests/features/status_command/json_output_is_machine_readable_test.py b/tests/features/status_command/json_output_is_machine_readable_test.py deleted file mode 100644 index a040292..0000000 --- a/tests/features/status_command/json_output_is_machine_readable_test.py +++ /dev/null @@ -1,250 +0,0 @@ -import json -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_json_output_includes_full_feature_hierarchy(tmp_project, config, capsys): - """JSON output has features array with full hierarchy and summary.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 2 - needs_fixes_stage = "needs fixes" - auth_scenarios = 3 - - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth Feature - Scenario: Login Works - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user sees "Welcome" - Scenario: Logout Works - Given a logged in user - When the user logs out - Then the user sees "Goodbye" - Scenario: Signup Works - Given a registration form - When the user submits email "alice@example.com" - Then the user is registered - """, - ) - write_test( - tmp_project, - "auth_feature", - "default_test.py", - """\ - def test_login_works(): - assert "alice" == "alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - - def test_logout_works(): - assert "Goodbye" == "Goodbye" - - def test_signup_works(): - assert "alice@example.com" == "alice@example.com" - """, - ) - - write_feature( - tmp_project, - "payment", - """\ - Feature: Payment Feature - Scenario: Charge Works - Given a payment method - When the charge is "100" - Then the charge is successful - Scenario: Refund Works - Given a previous charge - When the refund is "50" - Then the refund is successful - """, - ) - write_test( - tmp_project, - "payment_feature", - "default_test.py", - """\ - def test_charge_works(): - assert 1 == 1 - # missing "100" literal - - def test_refund_works(): - assert 2 == 2 - # missing "50" literal - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, json_output=True) - - captured = capsys.readouterr() - data = json.loads(captured.out) - assert "features" in data - assert len(data["features"]) == 2 - assert "summary" in data - assert data["summary"]["total_features"] == 2 - assert data["summary"]["ok"] == 1 - assert data["summary"]["needs_fixes"] == 1 - assert n_features == 2 - assert needs_fixes_stage == "needs fixes" - assert auth_scenarios == 3 - # Each feature has scenarios array - for feat in data["features"]: - assert "scenarios" in feat - - -def test_json_includes_summary_stage_counts(tmp_project, config, capsys): - """JSON summary has correct stage counts.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 3 - needs_bodies_stage = "needs bodies" - zero_count = 0 - - write_feature( - tmp_project, - "ok_feature", - """\ - Feature: Ok Feature - Scenario: Test Passes - Given a step literal "hello" - When action occurs - Then result is "world" - """, - ) - write_test( - tmp_project, - "ok_feature", - "default_test.py", - """\ - def test_test_passes(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - - write_feature( - tmp_project, - "broken_feature", - """\ - Feature: Broken Feature - @invalid tag whitespace - Given something - """, - ) - - write_feature( - tmp_project, - "stub_feature", - """\ - Feature: Stub Feature - Scenario: Stub Test - Given a step - When action - Then result - """, - ) - write_test( - tmp_project, - "stub_feature", - "default_test.py", - """\ - def test_stub_test(): - pass - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, json_output=True) - - captured = capsys.readouterr() - data = json.loads(captured.out) - s = data["summary"] - assert s["broken"] == 1 - assert s["needs_bodies"] == 1 - assert s["ok"] == 1 - assert n_features == 3 - assert needs_bodies_stage == "needs bodies" - assert zero_count == 0 - - -def test_json_has_collision_and_unmapped_entries(tmp_project, config, capsys): - """JSON output includes unmapped_directories and collisions.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - unmapped_dir = "tests/features/old_feature" - collision_fn = "test_login" - - # Unmapped directory - test_dir = tmp_project / "tests" / "features" / "old_feature" - test_dir.mkdir(parents=True, exist_ok=True) - (test_dir / "default_test.py").write_text("def test_old(): pass\n") - assert unmapped_dir == "tests/features/old_feature" - - # Two features with collision - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth - Scenario: Login - Given a login page - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "auth", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - write_feature( - tmp_project, - "sso", - """\ - Feature: SSO - Scenario: Login - Given an sso page - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "sso", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - assert collision_fn == "test_login" - - with pytest.raises(SystemExit): - compute_status(config, json_output=True, include_unmapped=True) - - captured = capsys.readouterr() - data = json.loads(captured.out) - assert len(data.get("unmapped_directories", [])) > 0 - assert len(data.get("collisions", [])) > 0 diff --git a/tests/features/status_command/ok_feature_collapses_in_output_test.py b/tests/features/status_command/ok_feature_collapses_in_output_test.py deleted file mode 100644 index 4abdb90..0000000 --- a/tests/features/status_command/ok_feature_collapses_in_output_test.py +++ /dev/null @@ -1,126 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_ok_feature_shown_as_tree_line(tmp_project, config, capsys): - """ok feature displayed as single collapsed line without scenario expansion.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - feature_title = "Fully Implemented" - expected_line = "ok fully_implemented (Fully Implemented)" - - write_feature( - tmp_project, - "fully_implemented", - """\ - Feature: Fully Implemented - Scenario: Login Works - Given a user named "Alice" with password "secret" is registered - When the user logs in with "Alice" and "secret" - Then the user sees "Welcome" - - Scenario: Logout Works - Given the user "Alice" is logged in - When the user clicks logout - Then the user sees "Goodbye" - """, - ) - - write_test( - tmp_project, - "fully_implemented", - "default_test.py", - """\ - def test_login_works(): - assert "Alice" == "Alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - - def test_logout_works(): - assert "Alice" == "Alice" - assert "Goodbye" == "Goodbye" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - lines = captured.out.strip().split("\n") - assert len(lines) == 1 - # the line matches "ok fully_implemented (Fully Implemented)" - assert "fully_implemented (Fully Implemented)" in lines[0] - assert feature_title == "Fully Implemented" - assert "ok" in lines[0] - assert expected_line == "ok fully_implemented (Fully Implemented)" - - -def test_two_ok_features_with_blank_separator(tmp_project, config, capsys): - """Two ok features shown separated by blank line, no scenario expansion.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - # Feature 1: auth with stage "ok" - _ok_stage = "ok" - write_feature( - tmp_project, - "auth", - """\ - Feature: Authentication - Scenario: Login Succeeds - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "authentication", - "default_test.py", - """\ - def test_login_succeeds(): - assert "alice" == "alice" - assert "secret" == "secret" - """, - ) - - # Feature 2: payment with stage "ok" - write_feature( - tmp_project, - "payment", - """\ - Feature: Payment - Scenario: Charge Works - Given a valid payment method "card" - When the charge is made - Then the charge is successful - """, - ) - write_test( - tmp_project, - "payment", - "default_test.py", - """\ - def test_charge_works(): - assert "card" == "card" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - out = captured.out - assert "auth (Authentication)" in out - assert "payment (Payment)" in out - # Blank line separates the two features - assert "\n\n" in out - assert "├──" not in out - # features have stage "ok" - assert "ok" in out diff --git a/tests/features/status_command/parse_error_captured_as_stage_test.py b/tests/features/status_command/parse_error_captured_as_stage_test.py deleted file mode 100644 index 12aae4d..0000000 --- a/tests/features/status_command/parse_error_captured_as_stage_test.py +++ /dev/null @@ -1,74 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature - - -def test_feature_missing_colon_after_scenario(tmp_project, config, capsys): - """Feature file with missing colon after Scenario keyword produces broken stage.""" - # NOTE: spec gap — the BDD scenario at status_command.feature:39-50 - # uses "Scenario bad title" (no colon) which gherkin-official treats - # as description text, not invalid syntax. The content below triggers - # a genuine CompositeParserException. - features_dir = "docs/features" - tests_dir = "tests/features" - feature_path = "docs/features/bad_scenario.feature" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "bad_scenario", - """\ - Feature - Scenario: bad title - Given something - """, - ) - assert (tmp_project / feature_path).exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - # Tree output shows the feature with label "broken" - assert "broken" in captured.out - assert "bad_scenario" in captured.out - # parse_error_message contains expected Gherkin error substring - assert "expected:" in captured.out - assert "FeatureLine" in captured.out - # BDD step literals for traceability - _expected_error = "expected: #TagLine, #FeatureLine, #RuleLine, #Comment, #Empty" - zero_count = 0 - assert zero_count == 0 - - -def test_feature_with_unrecognized_gherkin_keyword(tmp_project, config, capsys): - """Feature containing invalid Gherkin syntax produces broken stage.""" - # NOTE: spec gap — the BDD scenario at status_command.feature:52-62 - # uses "Situation: misnamed step" which gherkin-official treats as - # description text. The content below triggers a genuine parse error. - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "unknown_keyword", - """\ - Feature: Unknown Keyword - @invalid scenario - Given something - """, - ) - assert (tmp_project / "docs/features/unknown_keyword.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "broken" in captured.out - assert "unknown_keyword" in captured.out - assert "no scenarios" not in captured.out - assert "A tag may not contain whitespace" in captured.out diff --git a/tests/features/status_command/rules_without_scenarios_detected_test.py b/tests/features/status_command/rules_without_scenarios_detected_test.py deleted file mode 100644 index d3053ec..0000000 --- a/tests/features/status_command/rules_without_scenarios_detected_test.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature - - -def test_feature_with_rules_and_no_scenarios(tmp_project, config, capsys): - """Feature with Rule nodes but no Scenario children reports 'needs scenarios'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "draft_rules", - """\ - Feature: Draft Rules - Rule: Authentication rules - Rule: Authorization rules - """, - ) - assert (tmp_project / "docs/features/draft_rules.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "draft_rules" in captured.out - assert "(Draft Rules)" in captured.out - assert "needs scenarios" in captured.out - # BDD: detect_empty_rules returns rule_titles with these - _rule1 = "Authentication rules" - _rule2 = "Authorization rules" - # scenarios_total is 0 - zero = 0 - assert zero == 0 diff --git a/tests/features/status_command/scenario_statuses_derive_from_discovery_test.py b/tests/features/status_command/scenario_statuses_derive_from_discovery_test.py deleted file mode 100644 index c89eb46..0000000 --- a/tests/features/status_command/scenario_statuses_derive_from_discovery_test.py +++ /dev/null @@ -1,164 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_scenario_with_no_matching_test_function(tmp_project, config, capsys): - """Scenario with no matching test function → 'no test'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_delete_item" - - write_feature( - tmp_project, - "unmapped_single", - """\ - Feature: Unmapped Feature - Scenario: Delete Item - Given an item exists - When the user deletes the item - Then the item is gone - """, - ) - assert scenario_name == "test_delete_item" - - # No test file written — all scenarios unmapped - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "no test" in captured.out - assert "needs tests" in captured.out - - -def test_scenario_with_matching_stub_test(tmp_project, config, capsys): - """Scenario with stub test → 'no body'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_create_item" - - write_feature( - tmp_project, - "stub_single", - """\ - Feature: Stub Feature - Scenario: Create Item - Given a form is open - When the user submits the form - Then a new item is created - """, - ) - assert scenario_name == "test_create_item" - - write_test( - tmp_project, - "stub_feature", - "default_test.py", - """\ - def test_create_item(): - pass - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "no body" in captured.out - assert "needs bodies" in captured.out - - -def test_scenario_non_stub_test_violations(tmp_project, config, capsys): - """Scenario with non-stub test and missing-literal → '1 error'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_update_item" - literal_value = "new" - violation_count = 1 - - write_feature( - tmp_project, - "missing", - """\ - Feature: Missing Literal Feature - Scenario: Update Item - Given an item with value "new" - When the user updates the item - Then the item is updated - """, - ) - assert scenario_name == "test_update_item" - assert literal_value == "new" - # missing-literal violation for "new" - _ = literal_value # second occurrence of "new" - - write_test( - tmp_project, - "missing_literal_feature", - "default_test.py", - """\ - def test_update_item(): - assert 1 == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "1 error" in captured.out - assert "needs fixes" in captured.out - assert violation_count == 1 - - -def test_scenario_non_stub_test_zero_violations(tmp_project, config, capsys): - """Scenario with non-stub test and zero violations → 'ok'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_list_items" - placeholder_val = "page" - - write_feature( - tmp_project, - "passing", - """\ - Feature: Passing Feature - Scenario: List Items - Given the page is - When the user lists items - Then items are shown - """, - ) - assert scenario_name == "test_list_items" - assert placeholder_val == "page" - # body_name_nodes contains "page" - _ = placeholder_val # second occurrence of "page" - - write_test( - tmp_project, - "passing_feature", - "default_test.py", - """\ - def test_list_items(): - page = 1 - assert page == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "ok" in captured.out diff --git a/tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py b/tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py deleted file mode 100644 index be16202..0000000 --- a/tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py +++ /dev/null @@ -1,92 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_syntax_error_test_file_unmaps_scenarios(tmp_project, config, capsys): - """Python syntax error in test file → all scenarios unmapped → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 2 - _feature_ref = "docs/features/syntax_error.feature" - - write_feature( - tmp_project, - "syntax_error", - """\ - Feature: Syntax Error Feature - Scenario: First Scenario - Given a step - When action occurs - Then result happens - Scenario: Second Scenario - Given another step - When action occurs - Then result happens - """, - ) - - # Write a test file with a Python syntax error - bad_test_path = "tests/features/syntax_error/default_test.py" - test_file = ( - tmp_project / "tests" / "features" / "syntax_error_feature" / "default_test.py" - ) - test_file.parent.mkdir(parents=True, exist_ok=True) - test_file.write_text("def test_first_scenario(\n pass\n") - assert bad_test_path == "tests/features/syntax_error/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert "no test" in captured.out - assert n_scenarios == 2 - no_test_count = 2 - assert no_test_count == 2 - - -def test_empty_test_file_unmaps_all_scenarios(tmp_project, config, capsys): - """Empty test file → all scenarios unmapped → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 1 - _feature_ref = "docs/features/empty_test.feature" - - write_feature( - tmp_project, - "empty_test", - """\ - Feature: Empty Test Feature - Scenario: Only Scenario - Given a step - When action occurs - Then result happens - """, - ) - - # Write an empty test file - empty_test_path = "tests/features/empty_test/default_test.py" - test_file = ( - tmp_project / "tests" / "features" / "empty_test_feature" / "default_test.py" - ) - test_file.parent.mkdir(parents=True, exist_ok=True) - test_file.write_text("") - assert empty_test_path == "tests/features/empty_test/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert "no test" in captured.out - assert n_scenarios == 1 - no_test_count = 1 - assert no_test_count == 1 diff --git a/tests/features/status_command/tree_output_shows_rule_hierarchy_test.py b/tests/features/status_command/tree_output_shows_rule_hierarchy_test.py deleted file mode 100644 index 851320b..0000000 --- a/tests/features/status_command/tree_output_shows_rule_hierarchy_test.py +++ /dev/null @@ -1,214 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_rule_with_mixed_status_joins_counts(tmp_project, config, capsys): - """Rule shows aggregated non-ok counts: '1 no body, 2 errors'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_rules = 1 - _feature_ref = "docs/features/mixed.feature" - rule_title = "Mixed status" - n_no_body = 2 # BDD: 1 no body - n_errors = 2 # BDD: 2 errors - - write_feature( - tmp_project, - "mixed", - """\ - Feature: Mixed Status Rules - Rule: Mixed status - Scenario: Scenario Ok - Given a step literal "hello" - When action occurs - Then result is "world" - - Scenario: Scenario No Body - Given a step literal "alpha" - When action occurs - Then result is "beta" - - Scenario: Scenario Two Errors - Given a step literal "gamma" - When action occurs - Then result placeholder is - """, - ) - - write_test( - tmp_project, - "mixed_status_rules", - "mixed_status_test.py", - """\ - def test_scenario_ok(): - assert "hello" == "hello" - assert "world" == "world" - - def test_scenario_no_body(): - pass - - def test_scenario_two_errors(): - assert 1 == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - # Rule "Mixed status" shows aggregation "1 no body, 2 errors" - assert "1 no body, 2 errors" in captured.out - assert rule_title == "Mixed status" - assert n_rules == 1 - assert n_errors == 2 - - -def test_feature_rules_shown_in_tree_output(tmp_project, config, capsys): - """Feature with 2 Rules shown with tree characters.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_rules = 2 - _feature_ref = "docs/features/ecommerce.feature" - cart_rule = "Cart operations" - checkout_rule = "Checkout flow" - cart_agg = "1 no body" - checkout_agg = "2 errors" - tree_branch = "├──" - tree_last = "└──" - - write_feature( - tmp_project, - "ecommerce", - """\ - Feature: Ecommerce Features - Rule: Cart operations - Scenario: Add Item - Given a step literal "alpha" - When action occurs - Then result is "beta" - Scenario: Remove Item - Given another literal "gamma" - When action happens - Then result is "delta" - Scenario: View Cart - Given a step literal "hello" - When action occurs - Then result is "world" - - Rule: Checkout flow - Scenario: Pay Now - Given a step literal "first" - When action occurs - Then result is - Scenario: Confirm Order - Given a step literal "second" - When action occurs - Then result is "done" - """, - ) - - write_test( - tmp_project, - "ecommerce_features", - "cart_operations_test.py", - """\ - def test_add_item(): - assert "alpha" == "alpha" - assert "beta" == "beta" - - def test_remove_item(): - pass - - def test_view_cart(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - write_test( - tmp_project, - "ecommerce_features", - "checkout_flow_test.py", - """\ - def test_pay_now(): - assert "first" == "first" - # missing outcome variable → violation - - def test_confirm_order(): - assert "second" == "second" - assert "done" == "done" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - # Rule "Cart operations" shows aggregation "1 no body" - assert cart_agg in captured.out - # Rule "Checkout flow" shows aggregation — BDD literal "2 errors" for traceability - _bdd_checkout_agg = "2 errors" - assert "1 error" in captured.out - # Rule "Cart operations" is connected with tree character "├──" - assert tree_branch in captured.out - assert cart_rule in captured.out - # Rule "Checkout flow" is connected with tree character "└──" - assert tree_last in captured.out - assert checkout_rule in captured.out - assert "├── Rule: Cart operations" in captured.out - assert "└── Rule: Checkout flow" in captured.out - assert n_rules == 2 - assert "│" in captured.out # continuation char for non-last rule - - -def test_failing_scenario_shows_violation_codes_inline(tmp_project, config, capsys): - """Scenario with violations shows missing identifiers inline.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_title = "checkout with valid payment" - missing_identifiers = "price, tax" - tax_literal = "tax" - - write_feature( - tmp_project, - "checkout", - """\ - Feature: Checkout Feature - Scenario: checkout with valid payment - Given a step with price - When the user pays with tax "99" - Then payment completes - """, - ) - - write_test( - tmp_project, - "checkout_feature", - "default_test.py", - """\ - def test_checkout_with_valid_payment(): - assert 1 == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - out = captured.out - assert "price" in out - assert "99" in out - assert scenario_title == "checkout with valid payment" - assert missing_identifiers == "price, tax" - assert tax_literal == "tax" diff --git a/tests/features/status_command/unmapped_directories_reported_when_flagged_test.py b/tests/features/status_command/unmapped_directories_reported_when_flagged_test.py deleted file mode 100644 index 3bdecc7..0000000 --- a/tests/features/status_command/unmapped_directories_reported_when_flagged_test.py +++ /dev/null @@ -1,104 +0,0 @@ -import os -import pytest - -from beehave.status import compute_status -from beehave.config import Config -from conftest import write_feature, write_test - - -def test_unmapped_directory_shown_with_flag(tmp_project, config, capsys): - """Test directory with no matching feature → reported when --include-unmapped.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - unmapped_path = "tests/features/removed_feature" - missing_feature = "docs/features/removed_feature.feature" - - # Create a test directory with a test file, but no feature file - test_dir = tmp_project / "tests" / "features" / "removed_feature" - test_dir.mkdir(parents=True, exist_ok=True) - (test_dir / "default_test.py").write_text("def test_something(): pass\n") - - # Verify unmapped path exists and feature file does not - assert unmapped_path == "tests/features/removed_feature" - assert not (tmp_project / missing_feature).exists() - - # Create a feature so compute_status doesn't exit 0 with no features - write_feature( - tmp_project, - "exists_only", - """\ - Feature: Exists Only - Scenario: Existing Scenario - Given a step literal "hello" - When action occurs - Then result is "world" - """, - ) - write_test( - tmp_project, - "exists_only", - "default_test.py", - """\ - def test_existing_scenario(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, include_unmapped=True) - - captured = capsys.readouterr() - assert "removed_feature" in captured.out - - -def test_unmapped_directory_not_shown_without_flag(tmp_project, config, capsys): - """Without --include-unmapped, unmapped directories are not reported.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - unmapped_path = "tests/features/removed_feature" - missing_feature = "docs/features/removed_feature.feature" - - # Create a test directory with a test file, but no feature file - test_dir = tmp_project / "tests" / "features" / "removed_feature" - test_dir.mkdir(parents=True, exist_ok=True) - (test_dir / "default_test.py").write_text("def test_something(): pass\n") - - # Verify unmapped path exists and feature file does not - assert unmapped_path == "tests/features/removed_feature" - assert not (tmp_project / missing_feature).exists() - - # Create a feature so compute_status doesn't exit 0 with no features - write_feature( - tmp_project, - "exists_only", - """\ - Feature: Exists Only - Scenario: Existing Scenario - Given a step literal "hello" - When action occurs - Then result is "world" - """, - ) - write_test( - tmp_project, - "exists_only", - "default_test.py", - """\ - def test_existing_scenario(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, include_unmapped=False) - - captured = capsys.readouterr() - assert "removed_feature" not in captured.out diff --git a/tests/features/status_command/unmapped_scenarios_derive_stage_test.py b/tests/features/status_command/unmapped_scenarios_derive_stage_test.py deleted file mode 100644 index 8c61476..0000000 --- a/tests/features/status_command/unmapped_scenarios_derive_stage_test.py +++ /dev/null @@ -1,109 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_with_three_scenarios_one_unmapped(tmp_project, config, capsys): - """Feature with 3 scenarios where 1 has no matching test function → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - total_scenarios = 3 - mapped_tests = 2 - unmapped_count = 1 - feature_file = "docs/features/partial.feature" - test_file = "tests/features/partial/default_test.py" - - write_feature( - tmp_project, - "partial", - """\ - Feature: Partial Feature Coverage - Scenario: Scenario One Runs - Given a step - When an action happens - Then a result occurs - - Scenario: Scenario Two Passes - Given another step - When an action happens - Then a result occurs - - Scenario: Scenario Three Exists - Given a third step - When an action happens - Then a result occurs - """, - ) - assert (tmp_project / feature_file).exists() - - # Test file matches 2 of 3 scenarios — "Scenario Three Exists" is unmapped - write_test( - tmp_project, - "partial_feature_coverage", - "default_test.py", - """\ - def test_scenario_one_runs(): - pass - - def test_scenario_two_passes(): - pass - """, - ) - _bdd_test_path = test_file # BDD: "tests/features/partial/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - - # scenario "test_scenario_three" has no matching function - _ = "test_scenario_three" - assert captured.out is not None # test_scenario_three literal present - - # scenario status of "test_scenario_three" is "no test" - assert "no test" in captured.out - - # scenarios_total is 3, scenarios_no_test is 1 - assert mapped_tests == 2 - assert total_scenarios == 3 - assert unmapped_count == 1 - - -def test_feature_with_all_scenarios_unmapped(tmp_project, config, capsys): - """Feature where no test file exists → all scenarios unmapped → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "unmapped", - """\ - Feature: Unmapped Feature - Scenario: First Scenario - Given a step - When action occurs - Then result happens - Scenario: Second Scenario - Given another step - When action occurs - Then result happens - """, - ) - assert (tmp_project / "docs/features/unmapped.feature").exists() - # no test file at tests/features/unmapped/default_test.py - _no_test_path = "tests/features/unmapped/default_test.py" - assert not (tmp_project / _no_test_path).exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert captured.out.count("no test") == 2 diff --git a/tests/features/status_command/violations_derive_stage_test.py b/tests/features/status_command/violations_derive_stage_test.py deleted file mode 100644 index 7f97c29..0000000 --- a/tests/features/status_command/violations_derive_stage_test.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_scenario_with_missing_literal(tmp_project, config, capsys): - """Non-stub test missing a Gherkin literal → violations → 'needs fixes'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - _feature_ref = "docs/features/missing_literal.feature" - total_scenarios = 3 - ok_scenarios = 2 - error_scenarios = 1 - missing_literal_val = "approved" - - write_feature( - tmp_project, - "missing_literal", - """\ - Feature: Missing Literal - Scenario: Users Can Search - Given the search page is open - When the user types "hello" in the search box - Then the user sees "world" - """, - ) - - # test "test_payment_approval" has body constant nodes missing literal "approved" - _test_name = "test_payment_approval" - assert missing_literal_val == "approved" - - write_test( - tmp_project, - "missing_literal", - "default_test.py", - """\ - def test_users_can_search(): - search_page = "open" - assert "hello" == "hello" - assert True - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - # scenario "test_payment_approval" status is "1 error" - assert "1 error" in captured.out - assert _test_name == "test_payment_approval" - # violations include missing-literal for "approved" - assert ok_scenarios == 2 - assert total_scenarios == 3 - assert error_scenarios == 1 - - -def test_feature_with_multiple_scenarios_having_violations(tmp_project, config, capsys): - """Feature with multiple non-stub scenarios, all having violations → 'needs fixes'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - _feature_ref = "docs/features/multi_viol.feature" - total_scenarios = 2 - error_scenarios = 2 - login_literal = "username" - logout_literal = "session" - - write_feature( - tmp_project, - "multi_violation", - """\ - Feature: Multi Violation - Scenario: First Action - Given a value of "alpha" - When action occurs - Then result is "beta" - - Scenario: Second Action - Given a value of "gamma" - When action occurs - Then result is "delta" - """, - ) - - # test "test_login" has missing-placeholder violation for "username" - _login_test = "test_login" - assert login_literal == "username" - # test "test_logout" has missing-literal violation for "session" - _logout_test = "test_logout" - assert logout_literal == "session" - - write_test( - tmp_project, - "multi_violation", - "default_test.py", - """\ - def test_first_action(): - assert 1 == 1 - - def test_second_action(): - assert 2 == 2 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - assert total_scenarios == 2 - assert error_scenarios == 2 diff --git a/tests/features/status_command/worst_scenario_wins_test.py b/tests/features/status_command/worst_scenario_wins_test.py deleted file mode 100644 index 9d6e84c..0000000 --- a/tests/features/status_command/worst_scenario_wins_test.py +++ /dev/null @@ -1,113 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_mixed_feature_with_all_scenario_statuses(tmp_project, config, capsys): - """Feature with ok stub and unmapped scenarios → stage derived from worst scenario.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 3 - _feature_ref = "docs/features/mixed.feature" - - write_feature( - tmp_project, - "mixed", - """\ - Feature: Mixed Status Feature - Scenario: Scenario A Ok - Given a step literal "hello" - When action occurs - Then result is "world" - - Scenario: Scenario B Stub - Given a step literal "alpha" - When action occurs - Then result is "beta" - - Scenario: Scenario C Unmapped - Given a step literal "gamma" - When action occurs - Then result is "delta" - """, - ) - - write_test( - tmp_project, - "mixed_status_feature", - "default_test.py", - """\ - def test_scenario_a_ok(): - assert "hello" == "hello" - assert "world" == "world" - - def test_scenario_b_stub(): - pass - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert "no test" in captured.out - assert "no body" in captured.out - # scenario A status is "ok" - assert "ok" in captured.out - assert n_scenarios == 3 - - -def test_mixed_feature_ok_and_error_scens(tmp_project, config, capsys): - """Feature with ok and error scenarios → stage 'needs fixes'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 2 - _feature_ref = "docs/features/ok_plus_errors.feature" - - write_feature( - tmp_project, - "ok_plus_errors", - """\ - Feature: Ok Plus Errors - Scenario: Scenario A Ok - Given a step literal "first" - When action occurs - Then result is - - Scenario: Scenario B Error - Given a step with placeholder - When action occurs - Then result is "done" - """, - ) - - write_test( - tmp_project, - "ok_plus_errors", - "default_test.py", - """\ - def test_scenario_a_ok(): - outcome = "success" - assert "first" == "first" - - def test_scenario_b_error(): - assert "done" == "done" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - assert "1 error" in captured.out - # scenario A status is "ok" - assert "ok" in captured.out - assert n_scenarios == 2 diff --git a/tests/features/title_validation/__init__.py b/tests/features/title_validation/__init__.py deleted file mode 100644 index aac365f..0000000 --- a/tests/features/title_validation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Feature tests for global title validation.""" diff --git a/tests/features/title_validation/duplicate_titles_are_detected_test.py b/tests/features/title_validation/duplicate_titles_are_detected_test.py deleted file mode 100644 index 4c48291..0000000 --- a/tests/features/title_validation/duplicate_titles_are_detected_test.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Tests for duplicate title detection across feature files.""" - -from pathlib import Path - -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_duplicate_feature_titles(tmp_project: Path, config: Config) -> None: - """Two feature files with same title (case-insensitive) produce violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file_1 = tmp_project / "docs/features/hive_activity.feature" - feature_title_1 = "Hive Activity" - scenario_title_1 = "guard bee inspects visitor" - - write_feature( - tmp_project, - "hive_activity", - f"""\ - Feature: {feature_title_1} - - Scenario: {scenario_title_1} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file_1.exists() - - feature_file_2 = tmp_project / "docs/features/hive_dup.feature" - feature_title_2 = "hive activity" - scenario_title_2 = "forager returns with nectar" - - write_feature( - tmp_project, - "hive_dup", - f"""\ - Feature: {feature_title_2} - - Scenario: {scenario_title_2} - Given a forager bee - When it returns with nectar - Then the nectar is deposited in a cell - """, - ) - assert feature_file_2.exists() - - result = validate_all_titles(config) - assert len(result) == 2 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-feature-title"} - - paths = {v.path for v in result} - assert len(paths) == 2 - assert any("hive_activity.feature" in p for p in paths) - assert any("hive_dup.feature" in p for p in paths) - - -def test_rule_matches_feature_title(tmp_project: Path, config: Config) -> None: - """A Rule title matching a Feature title is flagged as duplicate-rule-title.""" - feature_file = tmp_project / "docs/features/rule_v_feature.feature" - assert feature_file.parent.exists() - - feature_title = "Swarm Detection" - rule_title = "swarm detection" - scenario_title = "temperature rise triggers alert" - - write_feature( - tmp_project, - "rule_v_feature", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - - Scenario: {scenario_title} - Given a hive temperature sensor - When the temperature rises above 40 degrees - Then a swarm alert is triggered - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-rule-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("rule_v_feature.feature" in p for p in paths) - - -def test_scenario_matches_feature_title(tmp_project: Path, config: Config) -> None: - """A Scenario title matching a Feature title is flagged as duplicate-scenario-title.""" - feature_file = tmp_project / "docs/features/scenario_feat.feature" - assert feature_file.parent.exists() - - feature_title = "Guard Inspection" - scenario_title = "guard inspection" - - write_feature( - tmp_project, - "scenario_feat", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-scenario-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("scenario_feat.feature" in p for p in paths) - - -def test_scenario_matches_rule_title(tmp_project: Path, config: Config) -> None: - """A Scenario title matching a Rule title is flagged as duplicate-scenario-title.""" - feature_file = tmp_project / "docs/features/scenario_rule.feature" - assert feature_file.parent.exists() - - feature_title = "Hive Activity" - rule_title = "Foraging Patterns" - scenario_title = "Foraging Patterns" - - write_feature( - tmp_project, - "scenario_rule", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - - Scenario: {scenario_title} - Given a forager bee - When it returns with pollen - Then the pollen is deposited - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-scenario-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("scenario_rule.feature" in p for p in paths) - - -def test_duplicate_scenarios(tmp_project: Path, config: Config) -> None: - """Two scenarios in the same file with case-insensitive-equal titles.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/dup_scenarios.feature" - feature_title = "Hive Activity" - scenario_title_1 = "guard bee inspects visitor" - scenario_title_2 = "Guard Bee Inspects Visitor" - - write_feature( - tmp_project, - "dup_scenarios", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title_1} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - - Scenario: {scenario_title_2} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 2 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-scenario-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("dup_scenarios.feature" in p for p in paths) - - -def test_mixed_violation_types(tmp_project: Path, config: Config) -> None: - """One file yields both an invalid-title and a duplicate-title violation.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - mixed_file = tmp_project / "docs/features/mixed.feature" - mixed_feature_title = "Hive-Activity" - mixed_rule_title = "Hive Activity" - mixed_scenario_title = "forager returns with nectar" - - write_feature( - tmp_project, - "mixed", - f"""\ - Feature: {mixed_feature_title} - - Rule: {mixed_rule_title} - - Scenario: {mixed_scenario_title} - Given a forager bee - When it returns with nectar - Then the nectar is deposited - """, - ) - assert mixed_file.exists() - - other_file = tmp_project / "docs/features/other.feature" - other_feature_title = "Hive Activity" - other_scenario_title = "other scenario" - - write_feature( - tmp_project, - "other", - f"""\ - Feature: {other_feature_title} - - Scenario: {other_scenario_title} - Given a bee - When it does something - Then it happens - """, - ) - assert other_file.exists() - - result = validate_all_titles(config) - assert len(result) == 2 - - error_types = {v.error_type for v in result} - assert error_types == {"invalid-feature-title", "duplicate-rule-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("mixed.feature" in p for p in paths) diff --git a/tests/features/title_validation/title_charset_is_validated_test.py b/tests/features/title_validation/title_charset_is_validated_test.py deleted file mode 100644 index 2cfbc23..0000000 --- a/tests/features/title_validation/title_charset_is_validated_test.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tests for title character-set validation.""" - -from pathlib import Path - -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_feature_title_with_hyphen(tmp_project: Path, config: Config) -> None: - """A Feature title containing a hyphen is flagged as invalid-feature-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_title.feature" - feature_title = "Hive-Activity" - - write_feature( - tmp_project, - "bad_title", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "invalid" in violation.message.lower() - - -def test_rule_title_with_period(tmp_project: Path, config: Config) -> None: - """A Rule title containing a period is flagged as invalid-rule-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_rule.feature" - feature_title = "Period Rule" - rule_title = "Guard.Inspection" - - write_feature( - tmp_project, - "bad_rule", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-rule-title" - assert "invalid" in violation.message.lower() - - -def test_scenario_title_with_slash(tmp_project: Path, config: Config) -> None: - """A Scenario title containing a slash is flagged as invalid-scenario-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_scenario.feature" - feature_title = "Forward Slash Scenario" - scenario_title = "guard/bee/inspects" - - write_feature( - tmp_project, - "bad_scenario", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-scenario-title" - assert "invalid" in violation.message.lower() - - -def test_underscore_is_valid_charset(tmp_project: Path, config: Config) -> None: - r"""Underscores are valid characters in titles (matching ``\w``).""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/underscore.feature" - feature_title = "Login_Flow Authentication" - - write_feature( - tmp_project, - "underscore", - f"""\ - Feature: {feature_title} - - Scenario: user signs in with email - Given a user - When they sign in with email - Then they are authenticated - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 0 diff --git a/tests/features/title_validation/title_validation_blocks_generation_test.py b/tests/features/title_validation/title_validation_blocks_generation_test.py deleted file mode 100644 index bf0ce05..0000000 --- a/tests/features/title_validation/title_validation_blocks_generation_test.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests that title validation acts as a pre-flight gate during generation.""" - -import sys -from io import StringIO -from pathlib import Path - -import pytest -from conftest import write_feature - -from beehave.config import Config -from beehave.generate import generate_stubs - - -def test_preflight_blocks_generation(tmp_project: Path, config: Config) -> None: - """generate_stubs refuses to run when title validation finds violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - # Given: a valid feature file - write_feature( - tmp_project, - "hive_activity", - """\ - Feature: Hive Activity - - Scenario: guard bee inspects visitor - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - - # And: a feature file with an invalid title ("Invalid" has 1 word, - # failing the 2-6 word count rule; validate_all_titles will flag it) - write_feature( - tmp_project, - "bad_title", - """\ - Feature: Invalid - - Scenario: some scenario - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - - # Ensure no stale test output from prior runs - tests_root = tmp_project / "tests" / "features" - hive_test_dir = tests_root / "hive_activity" - - captured = StringIO() - saved_stderr = sys.stderr - sys.stderr = captured - - try: - with pytest.raises(SystemExit) as exc_info: - generate_stubs("hive_activity", config) - assert exc_info.value.code == 1, ( - "generate_stubs must exit with code 1 when pre-flight title " - "validation fails" - ) - finally: - sys.stderr = saved_stderr - - # Then: the output contains a violation for the invalid title - output = captured.getvalue() - assert "Invalid" in output, "expected violation for 'Invalid' in stderr output" - - # And: no test files or directories are created for hive_activity.feature - assert not hive_test_dir.exists(), ( - "no test files or directories must be created when pre-flight fails" - ) diff --git a/tests/features/title_validation/title_violations_included_in_check_test.py b/tests/features/title_validation/title_violations_included_in_check_test.py deleted file mode 100644 index af7be51..0000000 --- a/tests/features/title_validation/title_violations_included_in_check_test.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests that check_all includes title validation violations.""" - -from pathlib import Path - -from conftest import write_feature - -from beehave.check import check_all -from beehave.config import Config - - -def test_check_includes_title_and_scenario_violations( - tmp_project: Path, config: Config -) -> None: - """check_all returns both title violations and unmapped-scenario violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_title.feature" - feature_title = "Bad-Title" - scenario_title = "simple scenario" - - write_feature( - tmp_project, - "bad_title", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - # No matching test file is created; check_all will also - # produce an unmapped-scenario violation. - - result = check_all(config) - - error_types = {v.error_type for v in result} - assert "invalid-feature-title" in error_types, ( - f"expected invalid-feature-title, got {error_types}" - ) - assert "unmapped-scenario" in error_types, ( - f"expected unmapped-scenario, got {error_types}" - ) - - title_violations = [ - v - for v in result - if v.error_type.startswith("invalid-") or v.error_type.startswith("duplicate-") - ] - for tv in title_violations: - assert not tv.is_warning, ( - f"title violation '{tv.error_type}' should be an error, not a warning" - ) - - bad_title_violations = [ - v for v in result if v.error_type == "invalid-feature-title" - ] - assert len(bad_title_violations) == 1 - assert "Bad-Title" in bad_title_violations[0].message diff --git a/tests/features/title_validation/title_word_count_is_validated_test.py b/tests/features/title_validation/title_word_count_is_validated_test.py deleted file mode 100644 index fab0961..0000000 --- a/tests/features/title_validation/title_word_count_is_validated_test.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for title word-count validation (2-6 words).""" - -from pathlib import Path - -import pytest -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_feature_title_has_one_word(tmp_project: Path, config: Config) -> None: - """A one-word Feature title is flagged as invalid-feature-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/single_word.feature" - feature_title = "Activity" - - write_feature( - tmp_project, - "single_word", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "word" in violation.message.lower() - - -def test_seven_word_title(tmp_project: Path, config: Config) -> None: - """A seven-word Feature title is flagged as invalid-feature-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/seven_word_title.feature" - feature_title = "My Seven Word Feature Title Is Here" - - write_feature( - tmp_project, - "seven_word_title", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "word" in violation.message.lower() - - -@pytest.mark.skip(reason="not implemented") -def test_rule_title_has_seven_words() -> None: - """Rule title with seven words.""" - ... - - -@pytest.mark.skip(reason="not implemented") -def test_scenario_title_is_empty_string() -> None: - """Scenario title consisting of an empty string.""" - ... - - -def test_empty_title_after_strip(tmp_project: Path, config: Config) -> None: - """A whitespace-only title is equivalent to empty and is flagged.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/whitespace_title.feature" - feature_title = " " - - write_feature( - tmp_project, - "whitespace_title", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "non-empty" in violation.message.lower() diff --git a/tests/features/title_validation/valid_titles_produce_no_violations_test.py b/tests/features/title_validation/valid_titles_produce_no_violations_test.py deleted file mode 100644 index 74e03c2..0000000 --- a/tests/features/title_validation/valid_titles_produce_no_violations_test.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests that valid titles produce no violations.""" - -from pathlib import Path - -import pytest -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_single_valid_file(tmp_project: Path, config: Config) -> None: - """A single file with valid unique titles produces an empty violations list.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/hive_activity.feature" - feature_title = "Hive Activity" - rule_title = "Hive defense" - scenario_title = "guard bee inspects visitor" - - write_feature( - tmp_project, - "hive_activity", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert result == [] - - -def test_two_files_with_valid_unique_titles(tmp_project: Path, config: Config) -> None: - """Two files with distinct valid titles produce no violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file_1 = tmp_project / "docs/features/hive_activity.feature" - feature_title_1 = "Hive Activity" - rule_title_1 = "Hive defense" - scenario_title_1 = "guard bee inspects visitor" - - write_feature( - tmp_project, - "hive_activity", - f"""\ - Feature: {feature_title_1} - - Rule: {rule_title_1} - Scenario: {scenario_title_1} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file_1.exists() - - feature_file_2 = tmp_project / "docs/features/comb_construction.feature" - feature_title_2 = "Comb Construction" - rule_title_2 = "Wax Production" - scenario_title_2 = "worker builds hexagonal cells" - - write_feature( - tmp_project, - "comb_construction", - f"""\ - Feature: {feature_title_2} - - Rule: {rule_title_2} - Scenario: {scenario_title_2} - Given a worker bee - When it builds a cell - Then the cell is hexagonal - """, - ) - assert feature_file_2.exists() - - result = validate_all_titles(config) - assert result == [] - - -def test_minimum_word_count_title(tmp_project: Path, config: Config) -> None: - """A two-word title (minimum allowed) produces no violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/minimal.feature" - feature_title = "Minimal Title" - scenario_title = "simple test" - - write_feature( - tmp_project, - "minimal", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert result == [] - - -@pytest.mark.skip(reason="not implemented") -def test_maximum_word_count_title() -> None: - """A title with exactly six words passes validation.""" - ... diff --git a/tests/integration/idempotency_test.py b/tests/integration/idempotency_test.py index 9890372..91d34ae 100644 --- a/tests/integration/idempotency_test.py +++ b/tests/integration/idempotency_test.py @@ -3,8 +3,6 @@ import tempfile from pathlib import Path -import pytest - BASE_FEATURE = """\ Feature: Input Scenario: first scenario @@ -61,14 +59,12 @@ def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: return py_path.read_text() -@pytest.mark.pending def test_regenerate_preserves_existing_consumer_py_body() -> None: consumer_marker = "# consumer-authored marker line" regenerated = regenerate_over_body(BASE_FEATURE, consumer_marker) assert consumer_marker in regenerated -@pytest.mark.pending def test_regenerate_does_not_emit_py_when_py_present() -> None: consumer_body = ( "from beehave import step\n" @@ -81,7 +77,6 @@ def test_regenerate_does_not_emit_py_when_py_present() -> None: assert regenerated == consumer_body -@pytest.mark.pending def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: pyi = emit_test_pyi_for(EXTENDED_FEATURE) assert "test_second_scenario" in pyi diff --git a/tests/integration/parsing_test.py b/tests/integration/parsing_test.py index 31881ff..9f0589a 100644 --- a/tests/integration/parsing_test.py +++ b/tests/integration/parsing_test.py @@ -1,13 +1,11 @@ from __future__ import annotations -import pytest - BACKGROUND_WITH_PLACEHOLDER_FEATURE = """\ Feature: Parsing Background: Given a background step with token -Scenario: scenario +Scenario: Hive stays idle Given anything """ @@ -16,7 +14,7 @@ Background: Given a background step without placeholders -Scenario: scenario +Scenario: Hive stays idle Given anything """ @@ -31,11 +29,9 @@ def parse_feature_raises(feature_text: str) -> bool: return False -@pytest.mark.pending def test_background_step_with_placeholder_is_parse_error() -> None: assert parse_feature_raises(BACKGROUND_WITH_PLACEHOLDER_FEATURE) -@pytest.mark.pending def test_background_step_without_placeholder_parses_cleanly() -> None: assert not parse_feature_raises(BACKGROUND_CLEAN_FEATURE) diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py index 5b5d41c..9aa1be0 100644 --- a/tests/integration/roundtrip_test.py +++ b/tests/integration/roundtrip_test.py @@ -3,10 +3,8 @@ import tempfile from pathlib import Path -import pytest - ROUND_TRIP_FEATURE = """\ -Feature: Round Trip +Feature: Roundtrip Contract Scenario: round trip Given first step When second step @@ -32,20 +30,17 @@ def check_passes_for(feature_text: str, test_py_text: str) -> bool: return check(feature_text, test_py_text) -@pytest.mark.pending def test_check_passes_on_freshly_generated_py() -> None: py_text = emit_test_py_for(ROUND_TRIP_FEATURE) assert check_passes_for(ROUND_TRIP_FEATURE, py_text) -@pytest.mark.pending def test_check_fails_after_consumer_edits_step_text() -> None: py_text = emit_test_py_for(ROUND_TRIP_FEATURE) edited = py_text.replace("first step", "edited step text") assert not check_passes_for(ROUND_TRIP_FEATURE, edited) -@pytest.mark.pending def test_check_fails_after_consumer_removes_step_block() -> None: shorter_body = ( "from beehave import step\n" @@ -57,7 +52,6 @@ def test_check_fails_after_consumer_removes_step_block() -> None: assert not check_passes_for(ROUND_TRIP_FEATURE, shorter_body) -@pytest.mark.pending def test_check_passes_after_consumer_adds_body_content() -> None: body_with_extra = ( "from beehave import step\n" diff --git a/tests/integration/step_cm_test.py b/tests/integration/step_cm_test.py index a89b918..950ff31 100644 --- a/tests/integration/step_cm_test.py +++ b/tests/integration/step_cm_test.py @@ -5,7 +5,6 @@ NOTE_FORMAT = "{keyword} {text}" -@pytest.mark.pending def test_block_body_executes_when_entered() -> None: from beehave import step @@ -15,7 +14,6 @@ def test_block_body_executes_when_entered() -> None: assert side_effect == ["entered"] -@pytest.mark.pending def test_assertion_inside_then_step_propagates_failure() -> None: from beehave import step @@ -23,7 +21,6 @@ def test_assertion_inside_then_step_propagates_failure() -> None: raise AssertionError -@pytest.mark.pending def test_assertion_inside_then_step_passes_when_truthy() -> None: from beehave import step @@ -31,7 +28,6 @@ def test_assertion_inside_then_step_passes_when_truthy() -> None: assert True -@pytest.mark.pending def test_exception_attributed_to_step_via_add_note() -> None: from beehave import step @@ -44,7 +40,6 @@ def test_exception_attributed_to_step_via_add_note() -> None: ] -@pytest.mark.pending def test_clean_exit_does_not_add_attribution_note() -> None: from beehave import step @@ -52,15 +47,13 @@ def test_clean_exit_does_not_add_attribution_note() -> None: pass -@pytest.mark.pending def test_keyword_and_text_are_positional_only() -> None: from beehave import step with pytest.raises(TypeError): - step(keyword="Then", text="the hive has honey") + step(keyword="Then", text="the hive has honey") # type: ignore[call-arg] -@pytest.mark.pending def test_placeholders_accepted_as_keyword_arguments() -> None: from beehave import step @@ -68,7 +61,6 @@ def test_placeholders_accepted_as_keyword_arguments() -> None: pass -@pytest.mark.pending def test_all_gherkin_step_keywords_accepted() -> None: from beehave import step @@ -78,7 +70,6 @@ def test_all_gherkin_step_keywords_accepted() -> None: pass -@pytest.mark.pending def test_localized_keyword_accepted() -> None: from beehave import step diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py index 151419f..4b251ab 100644 --- a/tests/integration/strategy_inference_test.py +++ b/tests/integration/strategy_inference_test.py @@ -3,8 +3,6 @@ import tempfile from pathlib import Path -import pytest - INT_COLUMN_FEATURE = """\ Feature: Strategy Inference Scenario Outline: int column @@ -94,37 +92,31 @@ def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: return pyi[start : end + 3] -@pytest.mark.pending def test_all_int_column_infers_int_parameter() -> None: signature = emitted_function_signature(INT_COLUMN_FEATURE, "int_column") assert "amount: int" in signature -@pytest.mark.pending def test_all_float_column_infers_float_parameter() -> None: signature = emitted_function_signature(FLOAT_COLUMN_FEATURE, "float_column") assert "amount: float" in signature -@pytest.mark.pending def test_all_bool_column_infers_bool_parameter() -> None: signature = emitted_function_signature(BOOL_COLUMN_FEATURE, "bool_column") assert "flag: bool" in signature -@pytest.mark.pending def test_mixed_type_column_infers_str_parameter() -> None: signature = emitted_function_signature(MIXED_COLUMN_FEATURE, "mixed_column") assert "amount: str" in signature -@pytest.mark.pending def test_text_column_infers_str_parameter() -> None: signature = emitted_function_signature(TEXT_COLUMN_FEATURE, "text_column") assert "name: str" in signature -@pytest.mark.pending def test_no_examples_table_infers_str_parameter() -> None: signature = emitted_function_signature(NO_EXAMPLES_FEATURE, "no_examples") assert "name: str" in signature diff --git a/tests/integration/title_derivation_test.py b/tests/integration/title_derivation_test.py index 6e5b3fe..6ec7198 100644 --- a/tests/integration/title_derivation_test.py +++ b/tests/integration/title_derivation_test.py @@ -1,17 +1,17 @@ from __future__ import annotations -import pytest +from typing import cast MIN_WORD_COUNT = 2 MAX_WORD_COUNT = 6 def function_name_for_title(title: str) -> str: - from beehave.gherkin import parse_feature + from beehave.gherkin import Scenario, parse_feature feature_text = f"Feature: T\nScenario: {title}\nGiven any step\n" feature = parse_feature(feature_text) - return feature.children[0].function_name + return cast(Scenario, feature.children[0]).function_name def title_is_valid_in_feature(*titles: str) -> bool: @@ -26,17 +26,14 @@ def title_is_valid_in_feature(*titles: str) -> bool: return True -@pytest.mark.pending def test_title_lowered_to_slug() -> None: assert function_name_for_title("Honey Production") == "test_honey_production" -@pytest.mark.pending def test_whitespace_runs_collapse_to_single_underscore() -> None: assert function_name_for_title("Honey Production") == "test_honey_production" -@pytest.mark.pending def test_function_name_is_test_underscore_slug() -> None: assert ( function_name_for_title("forager returns with nectar") @@ -44,31 +41,25 @@ def test_function_name_is_test_underscore_slug() -> None: ) -@pytest.mark.pending def test_two_word_title_is_valid() -> None: assert title_is_valid_in_feature("Honey Production") -@pytest.mark.pending def test_six_word_title_is_valid() -> None: assert title_is_valid_in_feature("the forager returns to the hive") -@pytest.mark.pending def test_one_word_title_rejected() -> None: assert not title_is_valid_in_feature("Honey") -@pytest.mark.pending def test_seven_word_title_rejected() -> None: assert not title_is_valid_in_feature("the forager returns to the busy hive") -@pytest.mark.pending def test_title_with_hyphen_rejected() -> None: assert not title_is_valid_in_feature("Honey-Production") -@pytest.mark.pending def test_duplicate_titles_case_insensitive_rejected() -> None: assert not title_is_valid_in_feature("Hive Activity", "hive activity") diff --git a/tests/test_check.py b/tests/test_check.py deleted file mode 100644 index 8a2a045..0000000 --- a/tests/test_check.py +++ /dev/null @@ -1,406 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from conftest import write_feature, write_test - -from beehave.check import ( - _check_examples_bijection, - _check_literals, - _check_placeholders, - check_all, - check_pair, - check_single, -) -from beehave.config import Config -from beehave.generate import generate_stubs -from beehave.models import ( - ExamplesTable, - Literal, - Placeholder, - ScenarioInfo, - TestInfo, -) - - -def _make_si( - title: str = "test scenario", - function_name: str = "test_test_scenario", - placeholders: tuple[Placeholder, ...] = (), - literals: tuple[Literal, ...] = (), - examples: ExamplesTable | None = None, - is_outline: bool = False, - feature_path: str = "feat", - rule_path: str = "default_test", - feature_title: str = "Feat", - line: int = 1, -) -> ScenarioInfo: - return ScenarioInfo( - title=title, - function_name=function_name, - steps=(), - placeholders=placeholders, - literals=literals, - examples=examples, - is_outline=is_outline, - feature_title=feature_title, - feature_path=feature_path, - rule_path=rule_path, - line=line, - ) - - -def _make_ti( - function_name: str = "test_test_scenario", - body_name_nodes: tuple[str, ...] = (), - body_constant_nodes: tuple[object, ...] = (), - is_stub: bool = False, - example_rows: tuple[dict[str, object], ...] = (), - line: int = 4, -) -> TestInfo: - return TestInfo( - function_name=function_name, - body_name_nodes=body_name_nodes, - body_constant_nodes=body_constant_nodes, - is_stub=is_stub, - example_rows=example_rows, - line=line, - ) - - -class TestCheckPlaceholders: - def test_pass_when_present(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=("x",)) - assert _check_placeholders(si, ti, "test.py") == [] - - def test_fail_when_missing(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=()) - v = _check_placeholders(si, ti, "test.py") - assert len(v) == 1 - assert v[0].error_type == "missing-placeholder" - - def test_stub_skips_check(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=(), is_stub=True) - assert _check_placeholders(si, ti, "test.py") == [] - - def test_only_body_checked_not_given_kwargs(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = TestInfo( - function_name="test_test_scenario", - given_kwargs=("x",), - body_name_nodes=(), - is_stub=False, - line=4, - ) - v = _check_placeholders(si, ti, "test.py") - assert len(v) == 1 - assert v[0].error_type == "missing-placeholder" - - -class TestCheckLiterals: - def test_pass_when_present(self) -> None: - si = _make_si(literals=(Literal("hello", '"hello"'),)) - ti = _make_ti(body_constant_nodes=("hello",)) - assert _check_literals(si, ti, "test.py") == [] - - def test_fail_when_missing(self) -> None: - si = _make_si(literals=(Literal("hello", '"hello"'),)) - ti = _make_ti(body_constant_nodes=()) - v = _check_literals(si, ti, "test.py") - assert len(v) == 1 - assert v[0].error_type == "missing-literal" - - def test_stub_skips_check(self) -> None: - si = _make_si(literals=(Literal("hello", '"hello"'),)) - ti = _make_ti(body_constant_nodes=(), is_stub=True) - assert _check_literals(si, ti, "test.py") == [] - - -class TestCheckExamplesBijection: - def test_matching_rows_pass(self) -> None: - si = _make_si( - is_outline=True, - examples=ExamplesTable( - headers=("a", "b"), - rows=(("1", "2"), ("3", "4")), - ), - ) - ti = _make_ti( - example_rows=({"a": 1, "b": 2}, {"a": 3, "b": 4}), - ) - v = _check_examples_bijection(si, ti, "test.py", "feat.feature") - assert v == [] - - def test_missing_example_row(self) -> None: - si = _make_si( - is_outline=True, - examples=ExamplesTable( - headers=("a",), - rows=(("1",), ("2",)), - ), - ) - ti = _make_ti(example_rows=({"a": 1},)) - v = _check_examples_bijection(si, ti, "test.py", "feat.feature") - assert any("Examples row 2" in x.message for x in v) - - def test_extra_example_decorator(self) -> None: - si = _make_si( - is_outline=True, - examples=ExamplesTable( - headers=("a",), - rows=(("1",),), - ), - ) - ti = _make_ti(example_rows=({"a": 1}, {"a": 2})) - v = _check_examples_bijection(si, ti, "test.py", "feat.feature") - assert any("has no matching Examples row" in x.message for x in v) - - def test_non_outline_skips(self) -> None: - si = _make_si(is_outline=False) - ti = _make_ti() - assert _check_examples_bijection(si, ti, "test.py", "feat.feature") == [] - - -class TestCheckPair: - def test_unmapped_scenario(self) -> None: - si = _make_si() - v = check_pair(si, None, "test.py", "feat.feature") - assert len(v) == 1 - assert v[0].error_type == "unmapped-scenario" - - def test_matching_pair_clean(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=("x",)) - assert check_pair(si, ti, "test.py", "feat.feature") == [] - - -class TestCheckSingle: - def test_clean_stub_passes(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "clean", - """\ - Feature: Clean Check - Scenario: hello world - Given stuff - """, - ) - generate_stubs("clean", config) - violations = check_single(fp, config) - assert violations == [] - - def test_unmapped_test_detected(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "unmapped", - """\ - Feature: Unmapped Check - Scenario: exists now - Given stuff - """, - ) - generate_stubs("unmapped", config) - write_test( - tmp_project, - "unmapped_check", - "default_test.py", - """\ - def test_exists_now(): - ... - - def test_unmapped_function(): - ... - """, - ) - violations = check_single(fp, config) - types = [v.error_type for v in violations] - assert "unmapped-test" in types - - def test_missing_placeholder_detected( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "ph", - """\ - Feature: PH Check - Scenario: check ph - Given the hive has nectar - """, - ) - generate_stubs("ph", config) - write_test( - tmp_project, - "ph_check", - "default_test.py", - """\ - from hypothesis import given, strategies as st - - @given(amount=st.integers()) - def test_check_ph(amount): - assert True - """, - ) - violations = check_single(fp, config) - types = [v.error_type for v in violations] - assert "missing-placeholder" in types - - def test_missing_literal_detected(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "lit", - """\ - Feature: Lit Check - Scenario: check lit - Given the bee smells "rose" - """, - ) - generate_stubs("lit", config) - write_test( - tmp_project, - "lit_check", - "default_test.py", - """\ - def test_check_lit(): - assert True - """, - ) - violations = check_single(fp, config) - types = [v.error_type for v in violations] - assert "missing-literal" in types - - def test_misplaced_test_after_rule_removal( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "mv", - """\ - Feature: Move Check - Scenario: top level - Given stuff - - Rule: Sub Rule - Scenario: sub scenario - Given things - """, - ) - generate_stubs("mv", config) - write_feature( - tmp_project, - "mv", - """\ - Feature: Move Check - Scenario: top level - Given stuff - - Scenario: sub scenario - Given things - """, - ) - generate_stubs("mv", config) - fp = tmp_project / "docs" / "features" / "mv.feature" - violations = check_single(fp, config) - warnings = [v for v in violations if v.error_type == "misplaced-test"] - assert len(warnings) >= 1 - assert all(v.is_warning for v in warnings) - - def test_rule_based_files_checked(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "rules", - """\ - Feature: Rules Check - Rule: Alpha Rule - Scenario: alpha one - Given stuff - """, - ) - generate_stubs("rules", config) - violations = check_single(fp, config) - assert violations == [] - - -class TestCheckAll: - def test_checks_all_features(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "f1", - """\ - Feature: Feature One - Scenario: scenario one - Given stuff - """, - ) - write_feature( - tmp_project, - "f2", - """\ - Feature: Feature Two - Scenario: scenario two - Given stuff - """, - ) - generate_stubs("f1", config) - generate_stubs("f2", config) - violations = check_all(config) - assert violations == [] - - def test_detects_cross_feature_unmapped( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "cross", - """\ - Feature: Cross Check - Scenario: cross scenario - Given stuff - """, - ) - generate_stubs("cross", config) - write_test( - tmp_project, - "cross_check", - "default_test.py", - """\ - def test_cross_scenario(): - ... - - def test_unmapped(): - ... - """, - ) - violations = check_all(config) - types = [v.error_type for v in violations] - assert "unmapped-test" in types - - def test_subdirectory_features_found( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "cart/shopping", - """\ - Feature: Cart Shopping - Scenario: add item - Given stuff - """, - ) - write_feature( - tmp_project, - "smoke", - """\ - Feature: Smoke Test - Scenario: everything is fine - Given stuff - """, - ) - - generate_stubs("cart/shopping", config) - generate_stubs("smoke", config) - violations = check_all(config) - assert violations == [] diff --git a/tests/test_clean.py b/tests/test_clean.py deleted file mode 100644 index 3a2236c..0000000 --- a/tests/test_clean.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature, write_test - -from beehave.clean import clean_unmapped -from beehave.config import Config -from beehave.generate import generate_stubs - - -class TestCleanUnmapped: - def test_removes_stub_silently(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cln1", - """\ - Feature: Cln One - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cln1", config) - write_test( - tmp_project, - "cln_one", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_unmapped(): - ... - """, - ) - clean_unmapped("cln1", config) - content = ( - tmp_project / "tests" / "features" / "cln_one" / "default_test.py" - ).read_text() - assert "test_keep_me" in content - assert "test_unmapped" not in content - - def test_warns_on_non_stub( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "cln2", - """\ - Feature: Cln Two - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cln2", config) - write_test( - tmp_project, - "cln_two", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_real(): - assert True - """, - ) - clean_unmapped("cln2", config) - captured = capsys.readouterr() - assert "Warning" in captured.out - assert "test_real" in captured.out - test_dir = tmp_project / "tests" / "features" / "cln_two" / "default_test.py" - content = test_dir.read_text() - assert "test_real" in content - - def test_force_removes_non_stub(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cln3", - """\ - Feature: Cln Three - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cln3", config) - write_test( - tmp_project, - "cln_three", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_real(): - assert True - """, - ) - clean_unmapped("cln3", config, force=True) - content = ( - tmp_project / "tests" / "features" / "cln_three" / "default_test.py" - ).read_text() - assert "test_keep_me" in content - assert "test_real" not in content - - def test_nonexistent_feature_exits(self, tmp_project: Path, config: Config) -> None: - with pytest.raises(SystemExit): - clean_unmapped("nonexistent", config) - - def test_rule_based_clean(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "clnr", - """\ - Feature: Cln Rule - Rule: Alpha Rule - Scenario: keep alpha - Given stuff - """, - ) - generate_stubs("clnr", config) - test_file = ( - tmp_project / "tests" / "features" / "cln_rule" / "alpha_rule_test.py" - ) - content = ( - test_file.read_text() - + """ -def test_unmapped(): - ... -""" - ) - test_file.write_text(content) - clean_unmapped("clnr", config) - content = test_file.read_text() - assert "test_keep_alpha" in content - assert "test_unmapped" not in content diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 9983d69..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,221 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature, write_test - -from beehave.cli import main -from beehave.config import Config -from beehave.generate import generate_stubs - - -class TestCliGenerate: - def test_generate_creates_files(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cli_gen", - """\ - Feature: CLI Gen - Scenario: hello world - Given stuff - """, - ) - main(["generate", "cli_gen"]) - test_file = tmp_project / "tests" / "features" / "cli_gen" / "default_test.py" - assert test_file.exists() - - def test_generate_missing_feature_exits(self, tmp_project: Path) -> None: - with pytest.raises(SystemExit): - main(["generate", "nonexistent"]) - - -class TestCliCheck: - def test_check_clean_exits_zero(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cli_chk", - """\ - Feature: CLI Chk - Scenario: hello world - Given stuff - """, - ) - generate_stubs("cli_chk", config) - main(["check", "cli_chk"]) - - def test_check_violation_exits_one( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "cli_bad", - """\ - Feature: CLI Bad - Scenario: broken test - Given the hive has nectar - """, - ) - generate_stubs("cli_bad", config) - write_test( - tmp_project, - "cli_bad", - "default_test.py", - """\ - from hypothesis import given, strategies as st - - @given(amount=st.integers()) - def test_broken_test(amount): - assert True - """, - ) - with pytest.raises(SystemExit) as exc_info: - main(["check", "cli_bad"]) - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "missing-placeholder" in captured.out - - def test_check_all_no_args(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cli_all", - """\ - Feature: CLI All - Scenario: hello world - Given stuff - """, - ) - generate_stubs("cli_all", config) - main(["check"]) - - def test_check_warning_only_exits_zero( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "cli_warn", - """\ - Feature: CLI Warn - Scenario: top level - - Rule: Sub Rule - Scenario: sub level - Given stuff - """, - ) - generate_stubs("cli_warn", config) - write_feature( - tmp_project, - "cli_warn", - """\ - Feature: CLI Warn - Scenario: top level - - Scenario: sub level - Given stuff - """, - ) - generate_stubs("cli_warn", config) - main(["check", "cli_warn"]) - - -class TestCliClean: - def test_clean_removes_stub( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "cli_cln", - """\ - Feature: CLI Cln - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cli_cln", config) - write_test( - tmp_project, - "cli_cln", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_unmapped(): - ... - """, - ) - main(["clean", "cli_cln"]) - content = ( - tmp_project / "tests" / "features" / "cli_cln" / "default_test.py" - ).read_text() - assert "test_unmapped" not in content - - -class TestCliList: - def test_list_shows_features( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "list1", - """\ - Feature: List One - Scenario: hello world - Given stuff - """, - ) - write_feature( - tmp_project, - "list2", - """\ - Feature: List Two - Scenario: hello world - Given stuff - """, - ) - main(["list"]) - captured = capsys.readouterr() - assert "list1: List One" in captured.out - assert "list2: List Two" in captured.out - - def test_list_verbose( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "lv", - """\ - Feature: List Verbose - Scenario: hello world - Given stuff - """, - ) - generate_stubs("lv", config) - main(["list", "--verbose"]) - captured = capsys.readouterr() - assert "scenarios:" in captured.out - assert "stubs:" in captured.out - - def test_list_empty_exits_zero( - self, tmp_project: Path, capsys: pytest.CaptureFixture[str] - ) -> None: - main(["list"]) - captured = capsys.readouterr() - assert captured.out == "" - - def test_list_nested_feature( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "nested/inner", - """\ - Feature: Nested Inner - Scenario: hello world - Given stuff - """, - ) - main(["list"]) - captured = capsys.readouterr() - assert "nested/inner: Nested Inner" in captured.out diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 6e8db46..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from beehave.config import VALID_STRATEGIES, Config, load_config - - -class TestConfig: - def test_defaults(self) -> None: - c = Config() - assert c.features_dir == "docs/features" - assert c.tests_dir == "tests/features" - assert c.default_strategy == "text" - assert c.background_check_numeric is True - assert c.background_check_string is True - - def test_invalid_strategy_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid default_strategy"): - Config(default_strategy="bad") - - def test_strategy_expr(self) -> None: - assert Config(default_strategy="text").default_strategy_expr == "st.text()" - assert ( - Config(default_strategy="integers").default_strategy_expr == "st.integers()" - ) - assert Config(default_strategy="floats").default_strategy_expr == "st.floats()" - assert ( - Config(default_strategy="booleans").default_strategy_expr == "st.booleans()" - ) - - @given(st.sampled_from(list(VALID_STRATEGIES))) - @settings(max_examples=10) - def test_all_valid_strategies_accepted(self, strategy: str) -> None: - c = Config(default_strategy=strategy) - assert c.default_strategy == strategy - - def test_load_config_no_pyproject(self, tmp_path: Path) -> None: - c = load_config(tmp_path) - assert c.features_dir == "docs/features" - - def test_load_config_with_pyproject(self, tmp_path: Path) -> None: - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[tool.beehave]\nfeatures_dir = "custom/features"\n' - 'tests_dir = "custom/tests"\n' - 'default_strategy = "integers"\n' - "background_check_numeric = false\n", - encoding="utf-8", - ) - c = load_config(tmp_path) - assert c.features_dir == "custom/features" - assert c.tests_dir == "custom/tests" - assert c.default_strategy == "integers" - assert c.background_check_numeric is False - - def test_load_config_partial_override(self, tmp_path: Path) -> None: - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[tool.beehave]\nfeatures_dir = "my/features"\n', - encoding="utf-8", - ) - c = load_config(tmp_path) - assert c.features_dir == "my/features" - assert c.tests_dir == "tests/features" - assert c.background_check_numeric is True diff --git a/tests/test_discover.py b/tests/test_discover.py deleted file mode 100644 index 2516f9a..0000000 --- a/tests/test_discover.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_test - -from beehave.discover import ( - DiscoverError, - _extract_body_nodes, - _is_stub_body, - discover_tests, -) - - -class TestIsStubBody: - def test_pass_is_stub(self) -> None: - import ast - - body = ast.parse("pass").body - assert _is_stub_body(body) is True - - def test_ellipsis_is_stub(self) -> None: - import ast - - body = ast.parse("...").body - assert _is_stub_body(body) is True - - def test_real_code_not_stub(self) -> None: - import ast - - body = ast.parse("x = 1\ny = 2").body - assert _is_stub_body(body) is False - - def test_docstring_plus_pass_not_stub(self) -> None: - import ast - - body = ast.parse('"docstring"\npass').body - assert _is_stub_body(body) is False - - -class TestExtractBodyNodes: - def test_names_extracted(self) -> None: - import ast - - body = ast.parse("x = y + z").body - names, _ = _extract_body_nodes(body) - assert "x" in names - assert "y" in names - assert "z" in names - - def test_constants_extracted(self) -> None: - import ast - - body = ast.parse('x = 42\ny = "hello"').body - _names, constants = _extract_body_nodes(body) - assert 42 in constants - assert "hello" in constants - - def test_leading_docstring_skipped(self) -> None: - import ast - - body = ast.parse('"This is a docstring"\nx = 1').body - _names, constants = _extract_body_nodes(body) - assert 1 in constants - - def test_empty_body(self) -> None: - - body: list = [] - names, constants = _extract_body_nodes(body) - assert names == () - assert constants == () - - -class TestDiscoverTests: - def test_discovers_test_functions(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "myfeat", - "default_test.py", - """\ - from hypothesis import given, strategies as st - - @given(x=st.integers()) - def test_something(x): - assert x == x - - def test_plain(): - pass - """, - ) - result = discover_tests(p) - assert "test_something" in result - assert "test_plain" in result - assert result["test_something"].given_kwargs == ("x",) - assert result["test_plain"].given_kwargs == () - - def test_stub_detection(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "stubs", - "default_test.py", - """\ - def test_stub(): - ... - def test_real(): - assert True - """, - ) - result = discover_tests(p) - assert result["test_stub"].is_stub is True - assert result["test_real"].is_stub is False - - def test_example_rows_extracted(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "ex", - "default_test.py", - """\ - from hypothesis import example, given, strategies as st - - @example(x=1, y=2) - @example(x=3, y=4) - @given(x=st.integers(), y=st.integers()) - def test_outline(x, y): - ... - """, - ) - result = discover_tests(p) - ti = result["test_outline"] - assert len(ti.example_rows) == 2 - assert ti.example_rows[0] == {"x": 1, "y": 2} - - def test_body_name_nodes(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "names", - "default_test.py", - """\ - def test_names(amount): - result = process(amount) - assert result - """, - ) - result = discover_tests(p) - ti = result["test_names"] - assert "amount" in ti.body_name_nodes - assert "result" in ti.body_name_nodes - assert "process" in ti.body_name_nodes - - def test_body_constant_nodes(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "consts", - "default_test.py", - """\ - def test_consts(): - assert "floral" in scents - assert 42 == count - """, - ) - result = discover_tests(p) - ti = result["test_consts"] - assert "floral" in ti.body_constant_nodes - assert 42 in ti.body_constant_nodes - - def test_non_test_functions_ignored(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "non", - "default_test.py", - """\ - def helper(): - pass - """, - ) - result = discover_tests(p) - assert "helper" not in result - - def test_nonexistent_file_returns_empty(self) -> None: - result = discover_tests(Path("/nonexistent/file.py")) - assert result == {} - - def test_syntax_error_raises(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "bad", - "default_test.py", - "def test_broken(\n", - ) - with pytest.raises(DiscoverError): - discover_tests(p) diff --git a/tests/test_generate.py b/tests/test_generate.py deleted file mode 100644 index a13a915..0000000 --- a/tests/test_generate.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature -from hypothesis import given, settings -from hypothesis import strategies as st - -from beehave.config import Config -from beehave.generate import ( - _build_import_block, - _generate_function, - _infer_strategy_from_examples, - _parse_existing_imports, - generate_stubs, -) -from beehave.models import ( - ExamplesTable, - Placeholder, - ScenarioInfo, - coerce_example_value, -) - - -def _make_si( - title: str = "test scenario", - function_name: str = "test_test_scenario", - placeholders: tuple[Placeholder, ...] = (), - examples: ExamplesTable | None = None, - is_outline: bool = False, - feature_path: str = "feat", - rule_path: str = "default_test", - feature_title: str = "Feat", -) -> ScenarioInfo: - return ScenarioInfo( - title=title, - function_name=function_name, - steps=(), - placeholders=placeholders, - literals=(), - examples=examples, - is_outline=is_outline, - feature_title=feature_title, - feature_path=feature_path, - rule_path=rule_path, - ) - - -class TestCoerceExampleValue: - def test_integer(self) -> None: - assert coerce_example_value("42") == 42 - - def test_negative_integer(self) -> None: - assert coerce_example_value("-5") == -5 - - def test_float(self) -> None: - result = coerce_example_value("3.14") - assert isinstance(result, float) - assert abs(result - 3.14) < 1e-10 - - def test_true(self) -> None: - assert coerce_example_value("true") is True - - def test_false(self) -> None: - assert coerce_example_value("false") is False - - def test_quoted_string(self) -> None: - assert coerce_example_value('"hello"') == "hello" - - def test_plain_text(self) -> None: - assert coerce_example_value("hello") == "hello" - - @given(st.integers(min_value=-1000, max_value=1000)) - @settings(max_examples=50) - def test_integer_roundtrip(self, n: int) -> None: - assert coerce_example_value(str(n)) == n - - @given(st.from_regex(r"-?\d+\.\d+", fullmatch=True)) - @settings(max_examples=30) - def test_float_detection(self, s: str) -> None: - result = coerce_example_value(s) - assert isinstance(result, float) - - -class TestInferStrategy: - def test_all_integers(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("1",), ("2",), ("3",))) - assert _infer_strategy_from_examples("x", table) == "st.integers()" - - def test_all_floats(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("1.0",), ("2.5",))) - assert _infer_strategy_from_examples("x", table) == "st.floats()" - - def test_all_booleans(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("true",), ("false",))) - assert _infer_strategy_from_examples("x", table) == "st.booleans()" - - def test_mixed_defaults_to_text(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("1",), ("hello",))) - assert _infer_strategy_from_examples("x", table) == "st.text()" - - -class TestBuildImportBlock: - def test_no_placeholders_no_import(self) -> None: - scenarios = {"test_a": _make_si(placeholders=())} - assert _build_import_block(scenarios) == [] - - def test_with_placeholders(self) -> None: - scenarios = {"test_a": _make_si(placeholders=(Placeholder("x"),))} - block = _build_import_block(scenarios) - assert len(block) == 2 - assert "from hypothesis import given, strategies as st" in block[0] - - def test_with_outline(self) -> None: - scenarios = { - "test_a": _make_si( - is_outline=True, - examples=ExamplesTable(headers=("x",), rows=(("1",),)), - ) - } - block = _build_import_block(scenarios) - assert any("example" in line for line in block) - - -class TestGenerateFunction: - def test_simple_function(self) -> None: - si = _make_si( - function_name="test_simple", - placeholders=(Placeholder("x"),), - ) - result = _generate_function(si, set(), Config()) - assert "def test_simple(x):" in result - assert "@given(x=st.text())" in result - assert " ..." in result - - def test_no_params(self) -> None: - si = _make_si(function_name="test_plain", placeholders=()) - result = _generate_function(si, set(), Config()) - assert "def test_plain():" in result - assert "@given" not in result - - def test_outline_generates_examples(self) -> None: - si = _make_si( - function_name="test_outline", - placeholders=(Placeholder("a"), Placeholder("b")), - is_outline=True, - examples=ExamplesTable( - headers=("a", "b"), - rows=(("1", "2"), ("3", "4")), - ), - ) - result = _generate_function(si, set(), Config()) - assert "@example(a=1, b=2)" in result - assert "@example(a=3, b=4)" in result - - -class TestParseExistingImports: - def test_extracts_hypothesis_imports(self) -> None: - source = "from hypothesis import given, strategies as st\n" - result = _parse_existing_imports(source) - assert "given" in result - assert "st" in result - - def test_non_hypothesis_ignored(self) -> None: - source = "from os import path\n" - result = _parse_existing_imports(source) - assert result == set() - - -class TestGenerateStubs: - def test_creates_default_test(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "gen1", - """\ - Feature: Gen One - Scenario: hello world - Given stuff - """, - ) - generate_stubs("gen1", config) - test_file = tmp_project / "tests" / "features" / "gen_one" / "default_test.py" - assert test_file.exists() - content = test_file.read_text() - assert "def test_hello_world():" in content - - def test_creates_rule_files(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "gen2", - """\ - Feature: Gen Two - Scenario: top level - - Rule: My Rule - Scenario: in rule - Given stuff - """, - ) - generate_stubs("gen2", config) - default = tmp_project / "tests" / "features" / "gen_two" / "default_test.py" - rule = tmp_project / "tests" / "features" / "gen_two" / "my_rule_test.py" - assert default.exists() - assert rule.exists() - assert "def test_top_level():" in default.read_text() - assert "def test_in_rule():" in rule.read_text() - - def test_idempotent(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "idem", - """\ - Feature: Idem Feature - Scenario: once only - Given stuff - """, - ) - generate_stubs("idem", config) - first = ( - tmp_project / "tests" / "features" / "idem_feature" / "default_test.py" - ).read_text() - generate_stubs("idem", config) - second = ( - tmp_project / "tests" / "features" / "idem_feature" / "default_test.py" - ).read_text() - assert first == second - - def test_scenario_outline_with_examples( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "outline", - """\ - Feature: Outline Feature - Scenario Outline: addition test - Given and - - Examples: - | a | b | - | 1 | 2 | - """, - ) - generate_stubs("outline", config) - content = ( - tmp_project / "tests" / "features" / "outline_feature" / "default_test.py" - ).read_text() - assert "@example(a=1, b=2)" in content - assert "@given(a=st.integers(), b=st.integers())" in content - - def test_nonexistent_feature_exits(self, tmp_project: Path, config: Config) -> None: - with pytest.raises(SystemExit): - generate_stubs("nonexistent", config) - - def test_creates_init_py(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "initcheck", - """\ - Feature: Init Check - Scenario: hello world - Given stuff - """, - ) - generate_stubs("initcheck", config) - init_file = tmp_project / "tests" / "features" / "init_check" / "__init__.py" - assert init_file.exists() - - def test_does_not_overwrite_existing_init_py( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "initexist", - """\ - Feature: Init Exist - Scenario: hello world - Given stuff - """, - ) - test_dir = tmp_project / "tests" / "features" / "init_exist" - test_dir.mkdir(parents=True, exist_ok=True) - init_file = test_dir / "__init__.py" - init_file.write_text("# custom init\n", encoding="utf-8") - generate_stubs("initexist", config) - assert init_file.read_text() == "# custom init\n" diff --git a/tests/test_gherkin.py b/tests/test_gherkin.py deleted file mode 100644 index ce98e35..0000000 --- a/tests/test_gherkin.py +++ /dev/null @@ -1,476 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature -from hypothesis import given, settings -from hypothesis import strategies as st - -from beehave.config import Config -from beehave.gherkin import ( - GherkinError, - _derive_feature_path, - _derive_function_name, - _derive_rule_path, - _extract_literals, - _extract_placeholders, - _validate_title, - parse_feature, -) - - -class TestValidateTitle: - def test_empty_raises(self) -> None: - with pytest.raises(GherkinError, match="non-empty"): - _validate_title("", "Scenario") - - def test_whitespace_only_raises(self) -> None: - with pytest.raises(GherkinError, match="non-empty"): - _validate_title(" ", "Scenario") - - def test_special_chars_raises(self) -> None: - with pytest.raises(GherkinError, match="invalid characters"): - _validate_title("hello@world", "Scenario") - - def test_valid_passes(self) -> None: - _validate_title("simple scenario", "Scenario") - - def test_unicode_letters_pass(self) -> None: - _validate_title("café résumé", "Scenario") - - @given(st.from_regex(r"[a-zA-Z]\w*", fullmatch=True)) - @settings(max_examples=50) - def test_valid_titles_never_raise(self, title: str) -> None: - _validate_title(title, "Scenario") - - -class TestDeriveFunctionName: - def test_simple(self) -> None: - assert _derive_function_name("hello world") == "test_hello_world" - - def test_extra_spaces_collapsed(self) -> None: - assert _derive_function_name("hello world") == "test_hello_world" - - def test_leading_trimming(self) -> None: - assert _derive_function_name(" hello ") == "test_hello" - - def test_invalid_identifier_raises(self) -> None: - with pytest.raises(GherkinError, match="not a valid Python identifier"): - _derive_function_name("hello-world") - - def test_uppercase_lowered(self) -> None: - assert _derive_function_name("Add Single Item") == "test_add_single_item" - - def test_mixed_case_lowered(self) -> None: - assert _derive_function_name("EvErYtHiNg") == "test_everything" - - def test_case_insensitive_collision(self) -> None: - assert _derive_function_name("Test") == _derive_function_name("tEsT") - - @given(st.from_regex(r"[a-zA-Z_][a-zA-Z0-9_]*", fullmatch=True)) - @settings(max_examples=50) - def test_single_word_always_valid(self, word: str) -> None: - result = _derive_function_name(word) - assert result.startswith("test_") - assert result.isidentifier() - assert result == result.lower() - - -class TestDeriveFeaturePath: - def test_simple(self) -> None: - assert _derive_feature_path("Bank Account") == "bank_account" - - def test_extra_spaces(self) -> None: - assert _derive_feature_path("Bank Account") == "bank_account" - - def test_already_lowercase(self) -> None: - assert _derive_feature_path("bank") == "bank" - - -class TestDeriveRulePath: - def test_simple(self) -> None: - assert _derive_rule_path("Hive Defense") == "hive_defense" - - def test_matches_feature_path_algorithm(self) -> None: - assert _derive_rule_path("A B C") == _derive_feature_path("A B C") - - -class TestExtractPlaceholders: - def test_no_placeholders(self) -> None: - assert _extract_placeholders("hello world") == () - - def test_single(self) -> None: - result = _extract_placeholders("has grams") - assert len(result) == 1 - assert result[0].name == "amount" - - def test_duplicate_deduped(self) -> None: - result = _extract_placeholders(" and again") - assert len(result) == 1 - - def test_invalid_identifier_raises(self) -> None: - with pytest.raises(GherkinError, match="not a valid Python identifier"): - _extract_placeholders("<123bad>") - - def test_keyword_raises(self) -> None: - with pytest.raises(GherkinError, match="Python keyword"): - _extract_placeholders("") - - def test_builtin_raises(self) -> None: - with pytest.raises(GherkinError, match="shadows a Python builtin"): - _extract_placeholders("") - - @given( - st.from_regex(r"[a-zA-Z_][a-zA-Z0-9_]*", fullmatch=True).filter( - lambda s: ( - s - not in ( - "len", - "str", - "int", - "float", - "bool", - "list", - "dict", - "set", - "tuple", - "type", - "print", - "input", - "range", - "enumerate", - "zip", - "map", - "filter", - "sorted", - "reversed", - "open", - "isinstance", - "issubclass", - "hasattr", - "getattr", - "setattr", - "delattr", - "property", - "staticmethod", - "classmethod", - "super", - "object", - "Exception", - "BaseException", - "True", - "False", - "None", - ) - ) - ) - ) - @settings(max_examples=30) - def test_valid_identifiers_extracted(self, name: str) -> None: - import keyword - - if keyword.iskeyword(name): - with pytest.raises(GherkinError): - _extract_placeholders(f"<{name}>") - else: - result = _extract_placeholders(f"<{name}>") - assert len(result) == 1 - assert result[0].name == name - - -class TestExtractLiterals: - def test_numeric(self) -> None: - result = _extract_literals("has 100 items") - assert len(result) == 1 - assert result[0].value == 100 - - def test_double_quoted_string(self) -> None: - result = _extract_literals('named "Alice"') - assert len(result) == 1 - assert result[0].value == "Alice" - assert result[0].raw == '"Alice"' - - def test_single_quoted_string(self) -> None: - result = _extract_literals("named 'Bob'") - assert len(result) == 1 - assert result[0].value == "Bob" - assert result[0].raw == "'Bob'" - - def test_both_quote_styles(self) -> None: - result = _extract_literals("""from 'home' to "work" """) - assert len(result) == 2 - values = [lit.value for lit in result] - assert "home" in values - assert "work" in values - - def test_no_literals(self) -> None: - assert _extract_literals("plain text here") == () - - def test_numeric_and_string(self) -> None: - result = _extract_literals('has 3 items named "foo"') - assert len(result) == 2 - - -class TestParseFeature: - def test_simple_scenario(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "simple", - """\ - Feature: Simple - Scenario: hello world - Given something - """, - ) - result = parse_feature(fp, config) - assert "test_hello_world" in result - si = result["test_hello_world"] - assert si.title == "hello world" - assert si.feature_title == "Simple" - assert si.feature_path == "simple" - assert si.rule_path == "default_test" - - def test_scenario_outline(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "outline", - """\ - Feature: Outline Test - Scenario Outline: addition - Given and - Then result is - - Examples: - | a | b | c | - | 1 | 2 | 3 | - """, - ) - result = parse_feature(fp, config) - si = result["test_addition"] - assert si.is_outline is True - assert si.examples is not None - assert si.examples.rows == (("1", "2", "3"),) - - def test_background_merged(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "bg", - """\ - Feature: Background Test - Background: - Given the system is ready - - Scenario: do something - Given a user exists - """, - ) - result = parse_feature(fp, config) - si = result["test_do_something"] - names = [ph.name for ph in si.placeholders] - assert "system" not in names - - def test_background_no_placeholders( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "bgph", - """\ - Feature: Bad Background - Background: - Given the hive has nectar - - Scenario: something - Given stuff - """, - ) - with pytest.raises(GherkinError, match="placeholder"): - parse_feature(fp, config) - - def test_rule_creates_separate_rule_path( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "rules", - """\ - Feature: Rule Test - Scenario: top level - - Rule: My Rule - Scenario: inside rule - """, - ) - result = parse_feature(fp, config) - assert result["test_top_level"].rule_path == "default_test" - assert result["test_inside_rule"].rule_path == "my_rule_test" - - def test_global_function_name_collision( - self, tmp_project: Path, config: Config - ) -> None: - fp1 = write_feature( - tmp_project, - "f1", - """\ - Feature: Feature One - Scenario: same name - Given stuff - """, - ) - fp2 = write_feature( - tmp_project, - "f2", - """\ - Feature: Feature Two - Scenario: same name - Given stuff - """, - ) - parse_feature(fp1, config) - with pytest.raises(GherkinError, match="collides"): - parse_feature( - fp2, config, seen_function_names={"test_same_name": "Feature One"} - ) - - def test_feature_not_found(self, tmp_project: Path, config: Config) -> None: - with pytest.raises(GherkinError, match="not found"): - parse_feature(tmp_project / "nonexistent.feature", config) - - def test_literal_extraction_from_steps( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "lits", - """\ - Feature: Literal Test - Scenario: literal check - Given the bee has 3 wings and "honey" scent - """, - ) - result = parse_feature(fp, config) - si = result["test_literal_check"] - values = [lit.value for lit in si.literals] - assert 3 in values - assert "honey" in values - - def test_background_literals_enforced_by_default( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "bglit", - """\ - Feature: BG Literals - Background: - Given the entrance has 2 guards - And the hive is "active" - - Scenario: check guards - Given a visitor arrives - """, - ) - result = parse_feature(fp, config) - si = result["test_check_guards"] - values = [lit.value for lit in si.literals] - assert 2 in values - assert "active" in values - - def test_background_literals_configurable(self, tmp_project: Path) -> None: - cfg = Config( - features_dir=str(tmp_project / "docs" / "features"), - tests_dir=str(tmp_project / "tests" / "features"), - background_check_numeric=False, - background_check_string=False, - ) - fp = write_feature( - tmp_project, - "bgconf", - """\ - Feature: BG Config - Background: - Given the entrance has 2 guards - And the hive is "active" - - Scenario: check - Given a visitor - """, - ) - result = parse_feature(fp, cfg) - si = result["test_check"] - assert all(not isinstance(lit.value, int) for lit in si.literals) - assert all(not isinstance(lit.value, str) for lit in si.literals) - - def test_background_steps_included_in_scenario_info( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "bgsteps", - """\ - Feature: BG Steps - Background: - Given a user exists - And the user has an empty cart - - Scenario: add item - When the user adds "Widget" to the cart - Then the cart contains 1 item - """, - ) - result = parse_feature(fp, config) - si = result["test_add_item"] - assert len(si.steps) == 4 - assert si.steps[0].text == "a user exists" - assert si.steps[1].text == "the user has an empty cart" - assert si.steps[2].text == 'the user adds "Widget" to the cart' - assert si.steps[3].text == "the cart contains 1 item" - - def test_rule_background_steps_included_in_scenario_info( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "rulebgsteps", - """\ - Feature: Rule BG Steps - Background: - Given feature bg step - - Rule: Sub - Background: - Given rule bg step - - Scenario: combined - Given scenario step - """, - ) - result = parse_feature(fp, config) - si = result["test_combined"] - assert len(si.steps) == 3 - assert si.steps[0].text == "feature bg step" - assert si.steps[1].text == "rule bg step" - assert si.steps[2].text == "scenario step" - - def test_rule_background_composes(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "rulebg", - """\ - Feature: Rule BG - Background: - Given feature bg - - Rule: Sub - Background: - Given rule bg with "special" - - Scenario: combined - Given scenario step - """, - ) - result = parse_feature(fp, config) - si = result["test_combined"] - values = [lit.value for lit in si.literals] - assert "special" in values diff --git a/uv.lock b/uv.lock index 526d242..1ed849d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.14" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "agents-smith" @@ -44,9 +48,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + [[package]] name = "beehave" -version = "0.4.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, @@ -58,9 +103,10 @@ dev = [ { name = "beehave" }, { name = "flowr", extra = ["viz"] }, { name = "hypothesis" }, + { name = "mypy" }, { name = "pytest" }, - { name = "pytest-beehave" }, { name = "ruff" }, + { name = "taskipy" }, ] [package.metadata] @@ -72,9 +118,10 @@ dev = [ { name = "beehave", editable = "." }, { name = "flowr", extras = ["viz"], specifier = ">=1.1.0" }, { name = "hypothesis", specifier = ">=6.152.7" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=8.0" }, - { name = "pytest-beehave", specifier = ">=0.2.2" }, { name = "ruff", specifier = ">=0.11" }, + { name = "taskipy", specifier = ">=1.14.1" }, ] [[package]] @@ -233,6 +280,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "mslex" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/97/7022667073c99a0fe028f2e34b9bf76b49a611afd21b02527fbfd92d4cd5/mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d", size = 11583, upload-time = "2024-10-16T13:16:18.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/f2/66bd65ca0139675a0d7b18f0bada6e12b51a984e41a76dbe44761bf1b3ee/mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4", size = 7820, upload-time = "2024-10-16T13:16:17.566Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -242,6 +373,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -251,6 +391,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psutil" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/5a/07871137bb752428aa4b659f910b399ba6f291156bdea939be3e96cae7cb/psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5", size = 508502, upload-time = "2024-12-19T18:21:20.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/99/ca79d302be46f7bdd8321089762dd4476ee725fce16fc2b2e1dbba8cac17/psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8", size = 247511, upload-time = "2024-12-19T18:21:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6b/73dbde0dd38f3782905d4587049b9be64d76671042fdcaf60e2430c6796d/psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377", size = 248985, upload-time = "2024-12-19T18:21:49.254Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/c319d31a1d3f88c5b79c68b3116c129e5133f1822157dd6da34043e32ed6/psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003", size = 284488, upload-time = "2024-12-19T18:21:51.638Z" }, + { url = "https://files.pythonhosted.org/packages/9c/39/0f88a830a1c8a3aba27fededc642da37613c57cbff143412e3536f89784f/psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160", size = 287477, upload-time = "2024-12-19T18:21:55.306Z" }, + { url = "https://files.pythonhosted.org/packages/47/da/99f4345d4ddf2845cb5b5bd0d93d554e84542d116934fde07a0c50bd4e9f/psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3", size = 289017, upload-time = "2024-12-19T18:21:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/38/53/bd755c2896f4461fd4f36fa6a6dcb66a88a9e4b9fd4e5b66a77cf9d4a584/psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53", size = 250602, upload-time = "2024-12-19T18:22:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444, upload-time = "2024-12-19T18:22:11.335Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -332,18 +487,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] -[[package]] -name = "pytest-beehave" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beehave" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/ea/6ac241d175e595f79403a4d1d8b8413c3cfb21dedfe81e6cd29a7d5b854c/pytest_beehave-0.2.2.tar.gz", hash = "sha256:e90a165c8b6853c545bbea971536ff4c81710623fe89bc0cd3e4f7c29a5f3406", size = 12896, upload-time = "2026-05-13T18:53:27.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/56/3d3cc2130d8cf800be084549f7c32351cb3ea5817ca865dae4098bee7fec/pytest_beehave-0.2.2-py3-none-any.whl", hash = "sha256:f0e9ab4bb5cc3d0abf5ad52d553b3762f42172db89fae40425bfb6882147ea12", size = 9973, upload-time = "2026-05-13T18:53:26.618Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -431,6 +574,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "taskipy" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mslex", marker = "sys_platform == 'win32'" }, + { name = "psutil" }, + { name = "tomli", marker = "python_full_version < '4'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/44/572261df3db9c6c3332f8618fafeb07a578fd18b06673c73f000f3586749/taskipy-1.14.1.tar.gz", hash = "sha256:410fbcf89692dfd4b9f39c2b49e1750b0a7b81affd0e2d7ea8c35f9d6a4774ed", size = 14475, upload-time = "2024-11-26T16:37:46.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/97/4e4cfb1391c81e926bebe3d68d5231b5dbc3bb41c6ba48349e68a881462d/taskipy-1.14.1-py3-none-any.whl", hash = "sha256:6e361520f29a0fd2159848e953599f9c75b1d0b047461e4965069caeb94908f1", size = 13052, upload-time = "2024-11-26T16:37:44.546Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 90b66ef8193a11316824db887d7d27295f3a7c70 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:25:30 -0400 Subject: [PATCH 3/9] docs(beehave-v2): regenerate docstrings + format --- beehave/__init__.py | 2 ++ beehave/check.py | 12 +++++++++--- beehave/cli.py | 8 ++++++++ beehave/generate.py | 8 ++++++++ beehave/gherkin.py | 24 ++++++++++++++++++++++++ beehave/status.py | 6 ++++++ beehave/step.py | 3 +++ 7 files changed, 60 insertions(+), 3 deletions(-) diff --git a/beehave/__init__.py b/beehave/__init__.py index dfebabc..7dec3f4 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,5 @@ +"""beehave — a thin Gherkin-style BDD layer on Hypothesis.""" + from beehave.step import step as step __version__ = "2.0.0" diff --git a/beehave/check.py b/beehave/check.py index 24edd1c..d00d8f2 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,3 +1,5 @@ +"""Verify a generated `_test.py` still matches its `.feature` source.""" + from __future__ import annotations import ast @@ -54,9 +56,7 @@ def _step_matches( return False if text != step.text: return False - if names != {p.name for p in step.placeholders}: - return False - return True + return names == {p.name for p in step.placeholders} def _scenario_matches( @@ -73,6 +73,12 @@ def _scenario_matches( def check(feature_text: str, test_py_text: str) -> bool: + """Return True iff every scenario matches a `def` one-to-one. + + Each scenario in `feature_text` must have a `def` in `test_py_text` whose + `with step(...)` blocks line up on keyword, text, and placeholder names. + A syntactically invalid test_py_text returns False rather than raising. + """ feature = parse_feature(feature_text) try: tree = ast.parse(test_py_text) diff --git a/beehave/cli.py b/beehave/cli.py index e8a2739..1025973 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,3 +1,5 @@ +"""Command-line entry point dispatching to generate, status, and check.""" + from __future__ import annotations import sys @@ -10,6 +12,12 @@ def main(argv: Sequence[str] | None = None) -> int: + """Dispatch the first argument to `generate`, `status`, or `check`. + + `generate` exits 0; `status` exits from status; `check` exits 1 if any + feature drifts from its generated test. Returns 2 for a missing or + unknown command. + """ args = list(sys.argv[1:] if argv is None else argv) if not args: return 2 diff --git a/beehave/generate.py b/beehave/generate.py index 55b0cfa..82e43ed 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -1,3 +1,5 @@ +"""Generate `_test.pyi` / `_test.py` pairs from `.feature` files.""" + from __future__ import annotations from pathlib import Path @@ -108,6 +110,12 @@ def _emit_group( def generate(root: Path) -> None: + """Read every `docs/features/*.feature` under `root` and emit test pairs. + + Writes a `{stem}_test.pyi` always and a `{stem}_test.py` only when absent, + per top-level scenario group and per `Rule:` group. Placeholder types are + inferred from each scenario's `Examples` values. + """ features_dir = root / "docs" / "features" tests_dir = root / "tests" tests_dir.mkdir(parents=True, exist_ok=True) diff --git a/beehave/gherkin.py b/beehave/gherkin.py index e215099..b07edb7 100644 --- a/beehave/gherkin.py +++ b/beehave/gherkin.py @@ -1,3 +1,5 @@ +"""Gherkin parse model — typed tree returned by `parse_feature`.""" + from __future__ import annotations import re @@ -7,15 +9,21 @@ class Placeholder: + """A captured `` placeholder found inside a step's text.""" + name: str class DataTable: + """Optional table on a step; `headers` is None when header-less.""" + headers: list[str] | None rows: list[list[str]] class Step: + """One Gherkin step: keyword, text, placeholders, optional docstring/table.""" + keyword: str text: str placeholders: list[Placeholder] @@ -24,15 +32,21 @@ class Step: class Examples: + """The `Examples:` block of a Scenario Outline.""" + headers: list[str] rows: list[dict[str, str]] class Background: + """Shared steps prepended to every scenario in the enclosing feature or rule.""" + steps: list[Step] class Scenario: + """A scenario with merged background + own steps and optional examples.""" + title: str slug: str function_name: str @@ -43,6 +57,8 @@ class Scenario: class Rule: + """A `Rule:` block — name, tags, optional background, and child scenarios.""" + name: str tags: list[str] background: Background | None @@ -50,6 +66,8 @@ class Rule: class Feature: + """The parsed feature — top-level scenarios and rules.""" + name: str tags: list[str] background: Background | None @@ -303,6 +321,12 @@ def _rule_from( def parse_feature(source: str) -> Feature: + """Parse `.feature` source into a `Feature`, enforcing title rules. + + Scenario titles must use Unicode letters/digits/spaces only, span two + to six words, and be case-insensitively unique within the feature. + Placeholders in any background step are rejected at parse time. + """ doc = cast(dict[str, Any], Parser().parse(source)) feature_data = cast(dict[str, Any], doc.get("feature") or {}) diff --git a/beehave/status.py b/beehave/status.py index b6c7aa2..8d6b467 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -1,7 +1,13 @@ +"""Report counts of feature files and generated stub files under a project root.""" + from pathlib import Path def status(root: Path) -> int: + """Print counts of `docs/features/*.feature` files and `tests/*_test.pyi` stubs. + + Returns 2 when the features directory is absent, else 0. + """ features_dir = root / "docs" / "features" if not features_dir.is_dir(): return 2 diff --git a/beehave/step.py b/beehave/step.py index 826204e..03eda45 100644 --- a/beehave/step.py +++ b/beehave/step.py @@ -1,3 +1,5 @@ +"""Step context manager for Gherkin steps.""" + from collections.abc import Iterator from contextlib import contextmanager @@ -9,6 +11,7 @@ def step( /, **placeholders: object, ) -> Iterator[None]: + """Attach the step's keyword and text as a note to any exception raised inside.""" try: yield except Exception as e: From d61425017b03beb4c601a219a09ad027480f8485 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:25:30 -0400 Subject: [PATCH 4/9] chore(beehave-v2): sync uv.lock to 2.0.0 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 1ed849d..a8d7b3f 100644 --- a/uv.lock +++ b/uv.lock @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "beehave" -version = "1.0.0" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, From dbc770e91c85e10e38f6c044e379f5f295df3743 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:44:56 -0400 Subject: [PATCH 5/9] docs: regenerate living spec for 2.0.0 Authored by refresh-cycle from the 55-test green suite (33 integration + 22 e2e) plus the source stubs, glossary, and absence of cassettes/migrations. Derived view per the simplicity-discipline rule; never hand-edited. --- docs/state.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/state.md diff --git a/docs/state.md b/docs/state.md new file mode 100644 index 0000000..05afeed --- /dev/null +++ b/docs/state.md @@ -0,0 +1,85 @@ +# Specification (current state) + +> The living specification of this project's current state — what it is and +> where it is in its build. Regenerated each pipeline cycle by the refresh step +> from the truth: test `.pyi`, pending marks, cassettes, migrations, and the +> glossary. Never hand-edited; hand-authoring creates a second source of truth +> that drifts. Tests are the source of truth for behaviour — this file is a +> derived view. When prose and a test disagree, the test wins. + +## Snapshot + +- **Project:** beehave +- **Version:** 2.0.0 +- **Generated:** 2026-07-18 by flowr session `beehave-v2` +- **Suite:** green · **Contracts:** 7/7 built (0 pending) +- **Purpose:** → see `README.md` + +## Entry points & boundaries + +**Entry points:** +- CLI: `beehave generate|check|status` (console-script `beehave = beehave.cli:main`). +- Import: `from beehave import step` — the runtime context manager used inside consumer-authored `*_test.py` bodies. + +**Boundary:** +- *Internal (ours):* `beehave.__init__`, `beehave.step`, `beehave.gherkin`, `beehave.generate`, `beehave.check`, `beehave.status`, `beehave.cli` (7 modules; parse-model shapes live in `beehave.gherkin` per Q2-resolution — no separate `models.py`). +- *External (depends on):* `gherkin-official` (parser; Full Gherkin coverage); Hypothesis (imported by generated `*_test.py`, NOT by the package); pytest (test runner, hosts the `step` CM); consumer-side mypy + `mypy.stubtest` (the gate — runs in consumer CI, not in-package). See Dependencies. + +## Contract index + +The derived map of what this system does. Each row points at the test (the +behavioural truth) and the source `.pyi` (the type surface). Intent is +regenerator-authored from the test body and self-corrects each cycle. + +| Contract | Module | Test | Intent (one line) | Status | +|---|---|---|---|---| +| `step` | `beehave.step` | `tests/integration/step_cm_test.py` | `step(keyword, text, /, **placeholders)` CM runs the block, attributes failures via `add_note`, positional-only keyword/text. | built | +| parse model + `parse_feature` | `beehave.gherkin` | `tests/integration/parsing_test.py`, `tests/integration/title_derivation_test.py` | Parse `.feature` into `Feature`/`Rule`/`Scenario`/`Step`/`Placeholder`/`Examples`/`Background`/`DataTable`; enforce title rules (case-insensitive uniqueness, 2–6 words, charset) and reject placeholders in Background. | built | +| `generate` | `beehave.generate` | `tests/integration/idempotency_test.py`, `tests/integration/strategy_inference_test.py` | Emit `_default_test.py{i,}` + one `__test.py{i,}` per Rule; `.pyi` always, `.py` skeleton only if absent; Examples-column → strategy inference (int/float/bool/str; no-Examples → str). | built | +| `check` | `beehave.check` | `tests/integration/roundtrip_test.py`, `tests/e2e/check_test.py` | Structural binding: `block[i]`↔`step[i]` on (keyword case-insensitive, text, placeholder-name-set); body-content NOT inspected (noise loophole closed). | built | +| `status` | `beehave.status` | `tests/e2e/status_test.py` | Print `.feature` count under `/docs/features/` and `*_test.pyi` count under `/tests/`; exit 0 if dir exists, 2 if missing. | built | +| `main` (CLI dispatch) | `beehave.cli` | `tests/e2e/{check,generate,status}_test.py` | Dispatch `beehave generate|check|status` and return each subcommand's exit code; `argv` defaults to `sys.argv[1:]`. | built | +| `__version__` + `step` re-export | `beehave.__init__` | (no test — Q10 deferral) | Single source of truth for `__version__ = "2.0.0"`; re-export `step` for `from beehave import step`. | built | + +## Composition & data flow + +How the contracts assemble into the e2e path, and the data that flows through +it. Entity names follow `docs/glossary.md`. + +``` +author → docs/features/.feature + → parse_feature(source: str) -> Feature + → generate(root: Path) -> None writes tests/_default_test.py{i,} + one __test.py{i,} per Rule + → consumer fills *_test.py bodies with `with step(keyword, text, **placeholders): ...` + → check(feature_text: str, test_py_text: str) -> bool walks with-step blocks in source order + → pytest runs the consumer's *_test.py; the step CM attributes any AssertionError to its step via add_note + → status(root: Path) -> int reports .feature count + *_test.pyi count + → consumer CI: mypy on beehave.* + mypy.stubtest (the gate — out of package) +``` + +**Data flow:** `str` (.feature text) → `Feature` (parse model) → filesystem write (`.pyi` always, `.py` skeleton-if-absent) → `bool` (check) / `int` (status, cli). No persistence layer — the cycle is stateless file emission (data-model §1). + +## Dependencies + +**External services** — wire shapes live in the cassettes (the authoritative +external contract); this table points at them and never restates the shape. + +| Service | Purpose | Protocol | Cassette | Env vars | +|---|---|---|---|---| +| `gherkin-official` | Parse Full Gherkin (`.feature` → typed tree) | in-process Python lib | N/A — explore pass-through; no HTTP | none | +| Hypothesis | Property-based strategies (`@given`/`@example`) for generated tests | in-process Python lib (consumer-side) | N/A — imported by generated `*_test.py`, not by beehave | none | +| pytest | Test runner hosting the `step` CM inside consumer `*_test.py` | in-process Python lib | N/A | none | +| mypy + `mypy.stubtest` | Consumer-side type gate + `.py`↔`.pyi` drift detector | CLI (consumer CI) | N/A — out-of-package per L3 non-blocks | none | + +**Persistence** — schema lives in the migrations (the migration IS the schema +spec); this table points at them and never restates the DDL. + +| Entity | Store | Migration | +|---|---|---| +| (none) | N/A — beehave v2 has no persistence layer (stateless file emission per data-model §1) | N/A | + +## Status & last cycle + +- **Built:** 7 · **Pending:** 0 — backlog: none +- **Last cycle:** the beehave-v2 rewrite — 5 build cycles (`step`, `gherkin`, `generate`, `check`, `status`+`cli`) shipped green at `2.0.0`; 55 tests across integration (33) and e2e (22); 3 build-escalations resolved (`parsing_test` 1-word filler, `roundtrip_test` feature-vs-scenario collision, `tests/e2e` pytester-chdir path bug). +- **Next:** none — shipped. v3 spec exploration lives under `docs/spec/v3/` for a future cycle's discovery pass. From 47f629f1782f4469f99f4052693520b6da6bd259 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:45:02 -0400 Subject: [PATCH 6/9] chore: strip docstrings for next dev cycle Module/class/function docstrings stripped from the 7 source .py modules (21 docstrings total) via AST line-range removal. Tests already docstring- free. ruff format applied; lint-merge + 55-test suite + whole-package stubtest all clean. --- beehave/__init__.py | 2 -- beehave/check.py | 8 -------- beehave/cli.py | 8 -------- beehave/generate.py | 8 -------- beehave/gherkin.py | 24 ------------------------ beehave/status.py | 6 ------ beehave/step.py | 3 --- 7 files changed, 59 deletions(-) diff --git a/beehave/__init__.py b/beehave/__init__.py index 7dec3f4..dfebabc 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,5 +1,3 @@ -"""beehave — a thin Gherkin-style BDD layer on Hypothesis.""" - from beehave.step import step as step __version__ = "2.0.0" diff --git a/beehave/check.py b/beehave/check.py index d00d8f2..9769b85 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,5 +1,3 @@ -"""Verify a generated `_test.py` still matches its `.feature` source.""" - from __future__ import annotations import ast @@ -73,12 +71,6 @@ def _scenario_matches( def check(feature_text: str, test_py_text: str) -> bool: - """Return True iff every scenario matches a `def` one-to-one. - - Each scenario in `feature_text` must have a `def` in `test_py_text` whose - `with step(...)` blocks line up on keyword, text, and placeholder names. - A syntactically invalid test_py_text returns False rather than raising. - """ feature = parse_feature(feature_text) try: tree = ast.parse(test_py_text) diff --git a/beehave/cli.py b/beehave/cli.py index 1025973..e8a2739 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,5 +1,3 @@ -"""Command-line entry point dispatching to generate, status, and check.""" - from __future__ import annotations import sys @@ -12,12 +10,6 @@ def main(argv: Sequence[str] | None = None) -> int: - """Dispatch the first argument to `generate`, `status`, or `check`. - - `generate` exits 0; `status` exits from status; `check` exits 1 if any - feature drifts from its generated test. Returns 2 for a missing or - unknown command. - """ args = list(sys.argv[1:] if argv is None else argv) if not args: return 2 diff --git a/beehave/generate.py b/beehave/generate.py index 82e43ed..55b0cfa 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -1,5 +1,3 @@ -"""Generate `_test.pyi` / `_test.py` pairs from `.feature` files.""" - from __future__ import annotations from pathlib import Path @@ -110,12 +108,6 @@ def _emit_group( def generate(root: Path) -> None: - """Read every `docs/features/*.feature` under `root` and emit test pairs. - - Writes a `{stem}_test.pyi` always and a `{stem}_test.py` only when absent, - per top-level scenario group and per `Rule:` group. Placeholder types are - inferred from each scenario's `Examples` values. - """ features_dir = root / "docs" / "features" tests_dir = root / "tests" tests_dir.mkdir(parents=True, exist_ok=True) diff --git a/beehave/gherkin.py b/beehave/gherkin.py index b07edb7..e215099 100644 --- a/beehave/gherkin.py +++ b/beehave/gherkin.py @@ -1,5 +1,3 @@ -"""Gherkin parse model — typed tree returned by `parse_feature`.""" - from __future__ import annotations import re @@ -9,21 +7,15 @@ class Placeholder: - """A captured `` placeholder found inside a step's text.""" - name: str class DataTable: - """Optional table on a step; `headers` is None when header-less.""" - headers: list[str] | None rows: list[list[str]] class Step: - """One Gherkin step: keyword, text, placeholders, optional docstring/table.""" - keyword: str text: str placeholders: list[Placeholder] @@ -32,21 +24,15 @@ class Step: class Examples: - """The `Examples:` block of a Scenario Outline.""" - headers: list[str] rows: list[dict[str, str]] class Background: - """Shared steps prepended to every scenario in the enclosing feature or rule.""" - steps: list[Step] class Scenario: - """A scenario with merged background + own steps and optional examples.""" - title: str slug: str function_name: str @@ -57,8 +43,6 @@ class Scenario: class Rule: - """A `Rule:` block — name, tags, optional background, and child scenarios.""" - name: str tags: list[str] background: Background | None @@ -66,8 +50,6 @@ class Rule: class Feature: - """The parsed feature — top-level scenarios and rules.""" - name: str tags: list[str] background: Background | None @@ -321,12 +303,6 @@ def _rule_from( def parse_feature(source: str) -> Feature: - """Parse `.feature` source into a `Feature`, enforcing title rules. - - Scenario titles must use Unicode letters/digits/spaces only, span two - to six words, and be case-insensitively unique within the feature. - Placeholders in any background step are rejected at parse time. - """ doc = cast(dict[str, Any], Parser().parse(source)) feature_data = cast(dict[str, Any], doc.get("feature") or {}) diff --git a/beehave/status.py b/beehave/status.py index 8d6b467..b6c7aa2 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -1,13 +1,7 @@ -"""Report counts of feature files and generated stub files under a project root.""" - from pathlib import Path def status(root: Path) -> int: - """Print counts of `docs/features/*.feature` files and `tests/*_test.pyi` stubs. - - Returns 2 when the features directory is absent, else 0. - """ features_dir = root / "docs" / "features" if not features_dir.is_dir(): return 2 diff --git a/beehave/step.py b/beehave/step.py index 03eda45..826204e 100644 --- a/beehave/step.py +++ b/beehave/step.py @@ -1,5 +1,3 @@ -"""Step context manager for Gherkin steps.""" - from collections.abc import Iterator from contextlib import contextmanager @@ -11,7 +9,6 @@ def step( /, **placeholders: object, ) -> Iterator[None]: - """Attach the step's keyword and text as a note to any exception raised inside.""" try: yield except Exception as e: From 64543293b566d98048a1739e02b026693b52e8b7 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:48:01 -0400 Subject: [PATCH 7/9] fix(beehave-v2): move pytest_plugins to root conftest pytest 9.0.3 forbids `pytest_plugins` in non-top-level conftests; the declaration in tests/e2e/conftest.py broke bare `task test` / `uv run pytest` collection (ERROR: Defining 'pytest_plugins' in a non-top-level conftest is no longer supported). Move it to a top-level conftest.py at the repo rootdir as pytest's deprecation notice recommends. The e2e conftest keeps its pending-marker hooks (nested hooks remain allowed). Verified: `task test`, `task lint`, `task lint-merge`, `task stubtest` all green; 55 passed. --- conftest.py | 1 + tests/e2e/conftest.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..c6481d5 --- /dev/null +++ b/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["pytester"] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index f05be32..245aacd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -2,8 +2,6 @@ import pytest -pytest_plugins = ["pytester"] - def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( From efa3d511f05bd2d5717f95eccf1ccd3b4f5dce57 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 13:31:44 -0400 Subject: [PATCH 8/9] feat(beehave-v3): scope generated tests to tests/features/ Emit beehave-generated *_test.{pyi,py} into tests/features/ (parallel to docs/features/) so the subdirectory is the ownership marker: anything inside is beehave-owned (wiped/regenerated), anything outside is consumer-owned (untouched). generate: emit into tests/features/, mkdir on demand, and wipe stale *_test.pyi in that subdir before each run so deleted scenarios/features stop orphaning files. check: _check_all now scans tests/features/ for *_test.py without a .pyi sibling (orphans) and fails with one stderr line per orphan before the AST binding loop. Hand-written tests outside tests/features/ are never inspected. status: counts *_test.pyi under tests/features/ (was tests/). Bumps version to 2.1.0. Signatures in the .pyi files are unchanged; the change is behavior + path scope only. Migration: move existing generated files from tests/ to tests/features/ (e.g. `mkdir -p tests/features && git mv tests/*_test.py{i,} tests/features/`) before re-running `beehave generate`. --- beehave/__init__.py | 2 +- beehave/cli.py | 13 +++++++++++- beehave/cli.pyi | 4 +++- beehave/generate.py | 4 +++- beehave/generate.pyi | 11 +++++----- beehave/status.py | 2 +- beehave/status.pyi | 4 ++-- pyproject.toml | 2 +- tests/e2e/check_test.py | 22 +++++++++++++++++++- tests/e2e/check_test.pyi | 2 ++ tests/e2e/generate_test.py | 18 +++++++++++++++- tests/e2e/generate_test.pyi | 2 ++ tests/e2e/status_test.py | 2 +- tests/integration/idempotency_test.py | 6 +++--- tests/integration/roundtrip_test.py | 2 +- tests/integration/strategy_inference_test.py | 2 +- uv.lock | 2 +- 17 files changed, 78 insertions(+), 22 deletions(-) diff --git a/beehave/__init__.py b/beehave/__init__.py index dfebabc..cc1f035 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,3 @@ from beehave.step import step as step -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/beehave/cli.py b/beehave/cli.py index e8a2739..41d812f 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -28,7 +28,18 @@ def _check_all(root: Path) -> int: features_dir = root / "docs" / "features" if not features_dir.is_dir(): return 1 - test_py_text = _read_test_py(root / "tests") + tests_features_dir = root / "tests" / "features" + if tests_features_dir.is_dir(): + orphans = [ + p + for p in sorted(tests_features_dir.glob("*_test.py")) + if not p.with_suffix(".pyi").exists() + ] + if orphans: + for orphan in orphans: + print(f"orphan: {orphan.name}", file=sys.stderr) + return 1 + test_py_text = _read_test_py(tests_features_dir) for feature_path in sorted(features_dir.glob("*.feature")): if not check(feature_path.read_text(), test_py_text): return 1 diff --git a/beehave/cli.pyi b/beehave/cli.pyi index 92c4c1e..65c7ea1 100644 --- a/beehave/cli.pyi +++ b/beehave/cli.pyi @@ -2,5 +2,7 @@ from collections.abc import Sequence # CLI entry: `beehave generate|check|status`. Returns the process exit code # (0 success; 2 missing features dir on `status`; non-zero on `check` -# structural-binding failure). `argv` defaults to `sys.argv[1:]` when None. +# structural-binding failure or when orphan `*_test.py` files without `.pyi` +# siblings are found in `/tests/features/`). `argv` defaults to +# `sys.argv[1:]` when None. def main(argv: Sequence[str] | None = None) -> int: ... diff --git a/beehave/generate.py b/beehave/generate.py index 55b0cfa..ea1967e 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -109,8 +109,10 @@ def _emit_group( def generate(root: Path) -> None: features_dir = root / "docs" / "features" - tests_dir = root / "tests" + tests_dir = root / "tests" / "features" tests_dir.mkdir(parents=True, exist_ok=True) + for stale in tests_dir.glob("*_test.pyi"): + stale.unlink() for feature_path in sorted(features_dir.glob("*.feature")): feature = parse_feature(feature_path.read_text()) diff --git a/beehave/generate.pyi b/beehave/generate.pyi index 1400f92..c3c4a30 100644 --- a/beehave/generate.pyi +++ b/beehave/generate.pyi @@ -1,9 +1,10 @@ from pathlib import Path # Emits `_default_test.py{i,}` plus one -# `__test.py{i,}` per Rule into `/tests/`, -# reading `/docs/features/*.feature`. Always emits `.pyi`; emits `.py` -# skeleton only when absent (idempotent — never clobbers consumer bodies). -# Background steps (Feature-level and Rule-level) are prepended to the -# relevant scenarios' `with step(...)` block lists in the emitted `.py`. +# `__test.py{i,}` per Rule into `/tests/features/`, +# reading `/docs/features/*.feature`. Wipes stale `*_test.pyi` in the emit +# dir before writing. Always emits `.pyi`; emits `.py` skeleton only when absent +# (idempotent — never clobbers consumer bodies). Background steps (Feature-level +# and Rule-level) are prepended to the relevant scenarios' `with step(...)` +# block lists in the emitted `.py`. def generate(root: Path) -> None: ... diff --git a/beehave/status.py b/beehave/status.py index b6c7aa2..8e5bcb2 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -6,7 +6,7 @@ def status(root: Path) -> int: if not features_dir.is_dir(): return 2 feature_count = len(list(features_dir.glob("*.feature"))) - tests_dir = root / "tests" + tests_dir = root / "tests" / "features" stub_count = len(list(tests_dir.glob("*_test.pyi"))) if tests_dir.is_dir() else 0 print(f"{feature_count} feature file(s)") print(f"{stub_count} stub file(s)") diff --git a/beehave/status.pyi b/beehave/status.pyi index 73eed86..84e7016 100644 --- a/beehave/status.pyi +++ b/beehave/status.pyi @@ -1,8 +1,8 @@ from pathlib import Path # Minimal `status` (journal Q3): prints `.feature` count under -# `/docs/features/` and `*_test.pyi` count under `/tests/` to -# stdout; returns 0 when the features directory exists, 2 when it is +# `/docs/features/` and `*_test.pyi` count under `/tests/features/` +# to stdout; returns 0 when the features directory exists, 2 when it is # missing (filesystem error). v1's rich stage taxonomy / `--json` / tree # output / unmapped-directory reporting are all dropped. def status(root: Path) -> int: ... diff --git a/pyproject.toml b/pyproject.toml index 29c0022..7f9de97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "2.0.0" +version = "2.1.0" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index ad49a7b..3b45269 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -23,9 +23,10 @@ def write_feature_text(pytester, basename: str, text: str) -> str: def write_test_py(pytester, stem: str, body: str) -> str: - dst = pytester.path / "tests" / f"{stem}_test.py" + dst = pytester.path / "tests" / "features" / f"{stem}_test.py" dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text(body) + dst.with_suffix(".pyi").write_text("") return str(dst) @@ -187,3 +188,22 @@ def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ) write_test_py(pytester, "minimal_default", body_without_literals) assert run_beehave_check(pytester) == 0 + + +def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + orphan_path = pytester.path / "tests" / "features" / "orphan_default_test.py" + orphan_path.write_text("# orphan") + result = pytester.run("beehave", "check") + assert result.ret != 0 + assert "orphan" in "\n".join(result.errlines) + + +def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + handwritten = pytester.path / "tests" / "handwritten_test.py" + handwritten.parent.mkdir(parents=True, exist_ok=True) + handwritten.write_text("# handwritten") + assert run_beehave_check(pytester) == 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi index b6f1b01..51556d1 100644 --- a/tests/e2e/check_test.pyi +++ b/tests/e2e/check_test.pyi @@ -24,3 +24,5 @@ def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: ... def test_passes_when_keyword_case_differs(pytester) -> None: ... def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: ... def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ... +def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: ... +def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: ... diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index 5523ed7..c9ee8eb 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -10,7 +10,7 @@ CASE_INSENSITIVE_MATCHING_FEATURE = "case_insensitive_matching.feature" DEFAULT_GROUP_SUFFIX = "default" -EMISSION_DIR = "tests" +EMISSION_DIR = "tests/features" def copy_feature_into_pytester(pytester, basename: str) -> str: @@ -171,3 +171,19 @@ def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: run_beehave_generate(pytester) pyi = read_emitted_pyi(pytester, "datatable_default") assert "unique_col" not in pyi + + +def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + stale_path = pytester.path / "tests" / "features" / "stale_default_test.pyi" + stale_path.parent.mkdir(parents=True, exist_ok=True) + stale_path.write_text("# stale") + run_beehave_generate(pytester) + assert not stale_path.exists() + + +def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + assert not (pytester.path / "tests" / "features").exists() + run_beehave_generate(pytester) + assert (pytester.path / "tests" / "features").is_dir() diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi index 5052946..3131d42 100644 --- a/tests/e2e/generate_test.pyi +++ b/tests/e2e/generate_test.pyi @@ -47,3 +47,5 @@ def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> N def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: ... def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: ... def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: ... +def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: ... +def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: ... diff --git a/tests/e2e/status_test.py b/tests/e2e/status_test.py index 88ae30b..e55df43 100644 --- a/tests/e2e/status_test.py +++ b/tests/e2e/status_test.py @@ -9,7 +9,7 @@ def write_feature_text(pytester, basename: str, text: str) -> str: def write_pyi_stub(pytester, stem: str) -> str: - dst = pytester.path / "tests" / f"{stem}_test.pyi" + dst = pytester.path / "tests" / "features" / f"{stem}_test.pyi" dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text("") return str(dst) diff --git a/tests/integration/idempotency_test.py b/tests/integration/idempotency_test.py index 91d34ae..4e911e1 100644 --- a/tests/integration/idempotency_test.py +++ b/tests/integration/idempotency_test.py @@ -28,7 +28,7 @@ def emit_test_py_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "input_default_test.py").read_text() + return (root / "tests" / "features" / "input_default_test.py").read_text() def emit_test_pyi_for(feature_text: str) -> str: @@ -40,7 +40,7 @@ def emit_test_pyi_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "input_default_test.pyi").read_text() + return (root / "tests" / "features" / "input_default_test.pyi").read_text() def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: @@ -51,7 +51,7 @@ def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: features = root / "docs" / "features" features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) - tests_dir = root / "tests" + tests_dir = root / "tests" / "features" tests_dir.mkdir(parents=True) py_path = tests_dir / "input_default_test.py" py_path.write_text(existing_py_body) diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py index 9aa1be0..1b378ad 100644 --- a/tests/integration/roundtrip_test.py +++ b/tests/integration/roundtrip_test.py @@ -21,7 +21,7 @@ def emit_test_py_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "input_default_test.py").read_text() + return (root / "tests" / "features" / "input_default_test.py").read_text() def check_passes_for(feature_text: str, test_py_text: str) -> bool: diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py index 4b251ab..02fdf5d 100644 --- a/tests/integration/strategy_inference_test.py +++ b/tests/integration/strategy_inference_test.py @@ -81,7 +81,7 @@ def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - pyi = (root / "tests" / "input_default_test.pyi").read_text() + pyi = (root / "tests" / "features" / "input_default_test.pyi").read_text() needle = f"def test_{scenario_slug}" start = pyi.find(needle) if start == -1: diff --git a/uv.lock b/uv.lock index a8d7b3f..5fe3a7c 100644 --- a/uv.lock +++ b/uv.lock @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "beehave" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, From 8a4bce0a0ea40934209981eb21226c7b2b9ec65d Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 15:01:15 -0400 Subject: [PATCH 9/9] feat(beehave-v4): emit parametrize for Examples, verify rows in check Replace v2's Hypothesis-strategy-inference path with @pytest.mark.parametrize for Scenario Outlines. The generator emits a parametrize decorator over each Outline's test function whose arg-names are the Examples headers and whose rows are the Examples cells (string tuples). All parameters are now typed `str`; the strategy-inference helpers (_is_int/_is_float/_is_bool/_infer_param_type) are removed. Plain scenarios (no Examples) emit no parametrize and no `import pytest`. Extend check.py to parse @pytest.mark.parametrize from each test function's decorator list and verify the arg-names and rows round-trip the feature's Examples table. Examples present without a matching parametrize now fails check (closing the Examples-row binding gap). Step-template binding (count/keyword/text/placeholder-names) is checked first; parametrize verification runs only for scenarios that have Examples. Bumps version to 2.2.0. .pyi signatures unchanged in shape (still flat typed signatures); comment prose updated to describe parametrize. Drops tests/integration/strategy_inference_test (the concept is gone) and replaces it with tests/integration/parametrize_test covering emission + check roundtrip. Adds one e2e test each in generate_test (parametrize emission via the CLI) and check_test (check catches edited parametrize rows via the CLI). --- beehave/__init__.py | 2 +- beehave/check.py | 75 +++++++++-- beehave/check.pyi | 5 +- beehave/generate.py | 59 +++------ beehave/generate.pyi | 4 +- pyproject.toml | 2 +- tests/e2e/check_test.py | 11 ++ tests/e2e/check_test.pyi | 1 + tests/e2e/generate_test.py | 9 ++ tests/e2e/generate_test.pyi | 1 + tests/integration/parametrize_test.py | 121 +++++++++++++++++ tests/integration/parametrize_test.pyi | 20 +++ tests/integration/strategy_inference_test.py | 122 ------------------ tests/integration/strategy_inference_test.pyi | 27 ---- uv.lock | 2 +- 15 files changed, 259 insertions(+), 202 deletions(-) create mode 100644 tests/integration/parametrize_test.py create mode 100644 tests/integration/parametrize_test.pyi delete mode 100644 tests/integration/strategy_inference_test.py delete mode 100644 tests/integration/strategy_inference_test.pyi diff --git a/beehave/__init__.py b/beehave/__init__.py index cc1f035..cb5a299 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,3 @@ from beehave.step import step as step -__version__ = "2.1.0" +__version__ = "2.2.0" diff --git a/beehave/check.py b/beehave/check.py index 9769b85..73408a5 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -6,7 +6,7 @@ from beehave.gherkin import Rule, parse_feature if TYPE_CHECKING: - from beehave.gherkin import Scenario, Step + from beehave.gherkin import Examples, Scenario, Step def _step_block_from_item( @@ -45,6 +45,46 @@ def _step_blocks( return blocks +def _parametrize_of( + function: ast.FunctionDef, +) -> tuple[list[str], list[tuple[str, ...]]] | None: + for decorator in function.decorator_list: + if not isinstance(decorator, ast.Call): + continue + func = decorator.func + if ( + not isinstance(func, ast.Attribute) + or func.attr != "parametrize" + or not isinstance(func.value, ast.Attribute) + or func.value.attr != "mark" + or not isinstance(func.value.value, ast.Name) + or func.value.value.id != "pytest" + ): + continue + if len(decorator.args) < 2: + continue + try: + arg_names = ast.literal_eval(decorator.args[0]) + rows = ast.literal_eval(decorator.args[1]) + except ValueError, SyntaxError: + continue + if not isinstance(arg_names, tuple) or not isinstance(rows, list): + continue + return (list(arg_names), [tuple(r) for r in rows]) + return None + + +def _examples_rows( + scenario: Scenario, +) -> tuple[list[str], list[tuple[str, ...]]] | None: + examples: Examples | None = scenario.examples + if examples is None: + return None + headers = list(examples.headers) + rows = [tuple(row[h] for h in headers) for row in examples.rows] + return (headers, rows) + + def _step_matches( block: tuple[str, str, set[str]], step: Step, @@ -60,14 +100,23 @@ def _step_matches( def _scenario_matches( scenario: Scenario, blocks_by_function: dict[str, list[tuple[str, str, set[str]]]], + parametrize_by_function: dict[str, tuple[list[str], list[tuple[str, ...]]] | None], ) -> bool: - blocks = blocks_by_function.get(scenario.function_name, []) + name = scenario.function_name + blocks = blocks_by_function.get(name, []) if len(blocks) != len(scenario.steps): return False - return all( + if not all( _step_matches(block, step) for block, step in zip(blocks, scenario.steps, strict=True) - ) + ): + return False + expected = _examples_rows(scenario) + if expected is not None: + actual = parametrize_by_function.get(name) + if actual is None or actual != expected: + return False + return True def check(feature_text: str, test_py_text: str) -> bool: @@ -76,14 +125,20 @@ def check(feature_text: str, test_py_text: str) -> bool: tree = ast.parse(test_py_text) except SyntaxError: return False - blocks_by_function = { - node.name: _step_blocks(node) - for node in tree.body - if isinstance(node, ast.FunctionDef) - } + blocks_by_function: dict[str, list[tuple[str, str, set[str]]]] = {} + parametrize_by_function: dict[ + str, tuple[list[str], list[tuple[str, ...]]] | None + ] = {} + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + blocks_by_function[node.name] = _step_blocks(node) + parametrize_by_function[node.name] = _parametrize_of(node) for child in feature.children: scenarios = child.children if isinstance(child, Rule) else [child] for scenario in scenarios: - if not _scenario_matches(scenario, blocks_by_function): + if not _scenario_matches( + scenario, blocks_by_function, parametrize_by_function + ): return False return True diff --git a/beehave/check.pyi b/beehave/check.pyi index 9d758ea..5ff4382 100644 --- a/beehave/check.pyi +++ b/beehave/check.pyi @@ -4,5 +4,8 @@ # (keyword-case-insensitively, text, placeholder-name-set). Returns True if # every block matches its step; False on any count, keyword, text, or # placeholder-name-set mismatch. Does NOT inspect the body for literals or -# placeholder AST nodes (v2 drops that layer entirely — Q5). +# placeholder AST nodes (v2 drops that layer entirely — Q5). For Scenario +# Outlines, additionally requires a `@pytest.mark.parametrize(...)` decorator +# whose arg-names and rows round-trip the feature's Examples table; missing or +# mismatched parametrize returns False. def check(feature_text: str, test_py_text: str) -> bool: ... diff --git a/beehave/generate.py b/beehave/generate.py index ea1967e..5f27c32 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -13,26 +13,6 @@ def _slug_from(title: str) -> str: return "_".join(title.split()).lower() -def _is_int(value: str) -> bool: - try: - int(value) - except ValueError: - return False - return True - - -def _is_float(value: str) -> bool: - try: - float(value) - except ValueError: - return False - return True - - -def _is_bool(value: str) -> bool: - return value.lower() in ("true", "false") - - def _placeholder_names(steps: list[Step]) -> list[str]: seen: set[str] = set() names: list[str] = [] @@ -44,26 +24,26 @@ def _placeholder_names(steps: list[Step]) -> list[str]: return names -def _infer_param_type(name: str, examples: Examples | None) -> str: - if examples is None: - return "str" - values = [row[name] for row in examples.rows if name in row] - if not values: - return "str" - if all(_is_int(v) for v in values): - return "int" - if all(_is_float(v) for v in values): - return "float" - if all(_is_bool(v) for v in values): - return "bool" - return "str" +def _signature_params(scenario: Scenario) -> str: + return ", ".join(f"{name}: str" for name in _placeholder_names(scenario.steps)) -def _signature_params(scenario: Scenario) -> str: - parts: list[str] = [] - for name in _placeholder_names(scenario.steps): - parts.append(f"{name}: {_infer_param_type(name, scenario.examples)}") - return ", ".join(parts) +def _parametrize_lines(scenario: Scenario) -> list[str]: + examples: Examples | None = scenario.examples + if examples is None: + return [] + arg_names = ", ".join(repr(h) for h in examples.headers) + lines = [ + "@pytest.mark.parametrize(", + f" ({arg_names}),", + " [", + ] + for row in examples.rows: + cells = ", ".join(repr(row[h]) for h in examples.headers) + lines.append(f" ({cells}),") + lines.append(" ],") + lines.append(")") + return lines def _render_pyi(scenarios: list[Scenario]) -> str: @@ -81,10 +61,13 @@ def _step_block(step: Step) -> str: def _render_py(scenarios: list[Scenario]) -> str: lines: list[str] = ["from beehave import step"] + if any(s.examples is not None for s in scenarios): + lines.append("import pytest") for scenario in scenarios: params = _signature_params(scenario) lines.append("") lines.append("") + lines.extend(_parametrize_lines(scenario)) lines.append(f"def {scenario.function_name}({params}) -> None:") for step in scenario.steps: lines.append(_step_block(step)) diff --git a/beehave/generate.pyi b/beehave/generate.pyi index c3c4a30..5b5cd77 100644 --- a/beehave/generate.pyi +++ b/beehave/generate.pyi @@ -6,5 +6,7 @@ from pathlib import Path # dir before writing. Always emits `.pyi`; emits `.py` skeleton only when absent # (idempotent — never clobbers consumer bodies). Background steps (Feature-level # and Rule-level) are prepended to the relevant scenarios' `with step(...)` -# block lists in the emitted `.py`. +# block lists in the emitted `.py`. Scenario Outline Examples are emitted as a +# `@pytest.mark.parametrize(...)` decorator over the test function (string rows, +# all params typed `str`); the `.pyi` carries only the flat typed signature. def generate(root: Path) -> None: ... diff --git a/pyproject.toml b/pyproject.toml index 7f9de97..55d67ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "2.1.0" +version = "2.2.0" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index 3b45269..d237f24 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -207,3 +207,14 @@ def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: handwritten.parent.mkdir(parents=True, exist_ok=True) handwritten.write_text("# handwritten") assert run_beehave_check(pytester) == 0 + + +def test_check_fails_when_parametrize_rows_edited(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + py_path = pytester.path / "tests" / "features" / "hive_activity_default_test.py" + edited = py_path.read_text().replace( + "('100', '20', '8', '80'),", "('999', '20', '8', '80')," + ) + py_path.write_text(edited) + assert run_beehave_check(pytester) != 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi index 51556d1..da00760 100644 --- a/tests/e2e/check_test.pyi +++ b/tests/e2e/check_test.pyi @@ -26,3 +26,4 @@ def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ... def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: ... def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: ... +def test_check_fails_when_parametrize_rows_edited(pytester) -> None: ... diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index c9ee8eb..6b9c066 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -187,3 +187,12 @@ def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: assert not (pytester.path / "tests" / "features").exists() run_beehave_generate(pytester) assert (pytester.path / "tests" / "features").is_dir() + + +def test_outline_scenario_emits_parametrize_in_py(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + py = read_emitted_py(pytester, "hive_activity_default") + assert "@pytest.mark.parametrize(" in py + assert "'nectar'" in py and "'honey'" in py + assert "('100', '20', '8', '80')," in py diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi index 3131d42..e52beb1 100644 --- a/tests/e2e/generate_test.pyi +++ b/tests/e2e/generate_test.pyi @@ -49,3 +49,4 @@ def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: ... def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: ... def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: ... def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: ... +def test_outline_scenario_emits_parametrize_in_py(pytester) -> None: ... diff --git a/tests/integration/parametrize_test.py b/tests/integration/parametrize_test.py new file mode 100644 index 0000000..80c32e4 --- /dev/null +++ b/tests/integration/parametrize_test.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +OUTLINE_FEATURE = """\ +Feature: Parametrize Emission +Scenario Outline: honey from nectar +Given the hive has grams +When the bees fan for hours +Then the hive produces grams + +Examples: + | nectar | hours | honey | + | 100 | 8 | 80 | + | 200 | 12 | 150 | +""" + +PLAIN_FEATURE = """\ +Feature: Parametrize Emission +Scenario: plain scenario +Given a step +Then anything +""" + + +def _emit(root: Path, feature_text: str) -> tuple[str, str]: + from beehave.generate import generate + + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + py = (root / "tests" / "features" / "input_default_test.py").read_text() + pyi = (root / "tests" / "features" / "input_default_test.pyi").read_text() + return py, pyi + + +def _emitted_py(feature_text: str) -> str: + with tempfile.TemporaryDirectory() as tmp: + py, _pyi = _emit(Path(tmp), feature_text) + return py + + +def _emitted_pyi(feature_text: str) -> str: + with tempfile.TemporaryDirectory() as tmp: + _py, pyi = _emit(Path(tmp), feature_text) + return pyi + + +def _check_result(feature_text: str, test_py_text: str) -> bool: + from beehave.check import check + + return check(feature_text, test_py_text) + + +def test_examples_scenario_emits_parametrize_decorator() -> None: + py = _emitted_py(OUTLINE_FEATURE) + assert "@pytest.mark.parametrize(" in py + + +def test_parametrize_arg_names_match_examples_headers() -> None: + py = _emitted_py(OUTLINE_FEATURE) + needle = py[py.find("@pytest.mark.parametrize(") :] + assert "'nectar'" in needle + assert "'hours'" in needle + assert "'honey'" in needle + + +def test_parametrize_rows_match_examples_rows() -> None: + py = _emitted_py(OUTLINE_FEATURE) + assert "('100', '8', '80')," in py + assert "('200', '12', '150')," in py + + +def test_no_examples_scenario_emits_no_parametrize() -> None: + py = _emitted_py(PLAIN_FEATURE) + assert "parametrize" not in py + assert "import pytest" not in py + + +def test_pyi_signature_carries_str_params_for_outline() -> None: + pyi = _emitted_pyi(OUTLINE_FEATURE) + assert ( + "def test_honey_from_nectar(nectar: str, hours: str, honey: str) -> None:" + in pyi + ) + + +def test_check_passes_when_parametrize_matches_examples() -> None: + py = _emitted_py(OUTLINE_FEATURE) + assert _check_result(OUTLINE_FEATURE, py) + + +def test_check_fails_when_examples_present_but_no_parametrize() -> None: + body_without_parametrize = ( + "from beehave import step\n" + "\n" + "def test_honey_from_nectar(nectar: str, hours: str, honey: str) -> None:\n" + ' with step("Given", "the hive has grams", nectar=nectar):\n' + " pass\n" + ' with step("When", "the bees fan for hours", hours=hours):\n' + " pass\n" + ' with step("Then", "the hive produces grams", honey=honey):\n' + " pass\n" + ) + assert not _check_result(OUTLINE_FEATURE, body_without_parametrize) + + +def test_check_fails_when_parametrize_rows_differ() -> None: + py = _emitted_py(OUTLINE_FEATURE) + edited = py.replace("('100', '8', '80'),", "('999', '8', '80'),") + assert edited != py + assert not _check_result(OUTLINE_FEATURE, edited) + + +def test_check_fails_when_parametrize_arg_names_differ() -> None: + py = _emitted_py(OUTLINE_FEATURE) + edited = py.replace("'nectar'", "'renamed'", 1) + assert edited != py + assert not _check_result(OUTLINE_FEATURE, edited) diff --git a/tests/integration/parametrize_test.pyi b/tests/integration/parametrize_test.pyi new file mode 100644 index 0000000..2c48fa0 --- /dev/null +++ b/tests/integration/parametrize_test.pyi @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + +OUTLINE_FEATURE: str +PLAIN_FEATURE: str + +def _emit(root: Path, feature_text: str) -> tuple[str, str]: ... +def _emitted_py(feature_text: str) -> str: ... +def _emitted_pyi(feature_text: str) -> str: ... +def _check_result(feature_text: str, test_py_text: str) -> bool: ... +def test_examples_scenario_emits_parametrize_decorator() -> None: ... +def test_parametrize_arg_names_match_examples_headers() -> None: ... +def test_parametrize_rows_match_examples_rows() -> None: ... +def test_no_examples_scenario_emits_no_parametrize() -> None: ... +def test_pyi_signature_carries_str_params_for_outline() -> None: ... +def test_check_passes_when_parametrize_matches_examples() -> None: ... +def test_check_fails_when_examples_present_but_no_parametrize() -> None: ... +def test_check_fails_when_parametrize_rows_differ() -> None: ... +def test_check_fails_when_parametrize_arg_names_differ() -> None: ... diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py deleted file mode 100644 index 02fdf5d..0000000 --- a/tests/integration/strategy_inference_test.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -import tempfile -from pathlib import Path - -INT_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: int column -Given a value of -Then anything - -Examples: - | amount | - | 1 | - | 2 | -""" - -FLOAT_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: float column -Given a value of -Then anything - -Examples: - | amount | - | 1.5 | - | 2.5 | -""" - -BOOL_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: bool column -Given a flag of -Then anything - -Examples: - | flag | - | true | - | false | -""" - -MIXED_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: mixed column -Given a value of -Then anything - -Examples: - | amount | - | 1 | - | 2.5 | - | hello | -""" - -TEXT_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: text column -Given a value of -Then anything - -Examples: - | name | - | alice | - | bob | -""" - -NO_EXAMPLES_FEATURE = """\ -Feature: Strategy Inference -Scenario: no examples -Given a value of -Then anything -""" - - -def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: - from beehave.generate import generate - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - features = root / "docs" / "features" - features.mkdir(parents=True) - (features / "input.feature").write_text(feature_text) - generate(root) - pyi = (root / "tests" / "features" / "input_default_test.pyi").read_text() - needle = f"def test_{scenario_slug}" - start = pyi.find(needle) - if start == -1: - return "" - end = pyi.find("...", start) - if end == -1: - return pyi[start:] - return pyi[start : end + 3] - - -def test_all_int_column_infers_int_parameter() -> None: - signature = emitted_function_signature(INT_COLUMN_FEATURE, "int_column") - assert "amount: int" in signature - - -def test_all_float_column_infers_float_parameter() -> None: - signature = emitted_function_signature(FLOAT_COLUMN_FEATURE, "float_column") - assert "amount: float" in signature - - -def test_all_bool_column_infers_bool_parameter() -> None: - signature = emitted_function_signature(BOOL_COLUMN_FEATURE, "bool_column") - assert "flag: bool" in signature - - -def test_mixed_type_column_infers_str_parameter() -> None: - signature = emitted_function_signature(MIXED_COLUMN_FEATURE, "mixed_column") - assert "amount: str" in signature - - -def test_text_column_infers_str_parameter() -> None: - signature = emitted_function_signature(TEXT_COLUMN_FEATURE, "text_column") - assert "name: str" in signature - - -def test_no_examples_table_infers_str_parameter() -> None: - signature = emitted_function_signature(NO_EXAMPLES_FEATURE, "no_examples") - assert "name: str" in signature diff --git a/tests/integration/strategy_inference_test.pyi b/tests/integration/strategy_inference_test.pyi deleted file mode 100644 index 9784795..0000000 --- a/tests/integration/strategy_inference_test.pyi +++ /dev/null @@ -1,27 +0,0 @@ -# Integration contract for Examples-table -> Hypothesis strategy -> `.pyi` type. -# -# The generation rule (interview L1): for each Examples column, the strategy is -# inferred from cell values across all rows - all-parseable int -> int, -# all-parseable float -> float, all-parseable bool -> bool, otherwise -> str. -# When a Scenario has no Examples table, placeholders default to `str` -# (decision Q4 - simplest sound default; matches the "otherwise -> str" branch). -# -# These tests drive `beehave.gherkin.parse_feature` + `beehave.generate`'s -# emission path in-process; the SUT imports live in each body (deferred), so -# the `.pyi` does not import beehave. - -# Minimal feature snippets exercising each strategy branch. -INT_COLUMN_FEATURE: str -FLOAT_COLUMN_FEATURE: str -BOOL_COLUMN_FEATURE: str -MIXED_COLUMN_FEATURE: str -TEXT_COLUMN_FEATURE: str -NO_EXAMPLES_FEATURE: str - -def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: ... -def test_all_int_column_infers_int_parameter() -> None: ... -def test_all_float_column_infers_float_parameter() -> None: ... -def test_all_bool_column_infers_bool_parameter() -> None: ... -def test_mixed_type_column_infers_str_parameter() -> None: ... -def test_text_column_infers_str_parameter() -> None: ... -def test_no_examples_table_infers_str_parameter() -> None: ... diff --git a/uv.lock b/uv.lock index 5fe3a7c..ac0d82a 100644 --- a/uv.lock +++ b/uv.lock @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "beehave" -version = "2.1.0" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" },