From 789d55cf0032751d05cf9c7fff2490cb0dfdc265 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 28 Feb 2026 14:26:00 -0500 Subject: [PATCH] feat: Spock-style implicit assertions for Then/Expect blocks Every non-assignment statement in Then/Expect blocks is now an assertion, matching Spock's core model. Binary operators (==, !=, =~, !~, >, <, >=, <=) map to specialized Minitest assertions; bare boolean expressions use assert_equal(true/false, expr) with the original source text in the error message. Negation (!expr) is detected automatically. Introduces StatementParser for classification and StatementToAssertionTransformation for Minitest mapping. Removes the old ComparisonToAssertionTransformation. Backfills CHANGELOG for all prior versions and updates README documentation. Made-with: Cursor --- CHANGELOG.md | 146 +++++++++++-- README.md | 49 ++++- .../comparison_to_assertion_transformation.rb | 40 ---- lib/rspock/ast/node.rb | 15 ++ lib/rspock/ast/parser/expect_block.rb | 7 + lib/rspock/ast/parser/statement_parser.rb | 48 +++++ lib/rspock/ast/parser/then_block.rb | 13 +- .../statement_to_assertion_transformation.rb | 68 +++++++ lib/rspock/ast/test_method_transformation.rb | 17 +- test/example_rspock_test.rb | 58 ++++++ ...arison_to_assertion_transformation_test.rb | 94 --------- test/rspock/ast/parser/expect_block_test.rb | 4 +- .../ast/parser/statement_parser_test.rb | 191 ++++++++++++++++++ test/rspock/ast/parser/then_block_test.rb | 15 +- ...tement_to_assertion_transformation_test.rb | 155 ++++++++++++++ test/transformation_helper.rb | 1 - 16 files changed, 743 insertions(+), 178 deletions(-) delete mode 100644 lib/rspock/ast/comparison_to_assertion_transformation.rb create mode 100644 lib/rspock/ast/parser/statement_parser.rb create mode 100644 lib/rspock/ast/statement_to_assertion_transformation.rb delete mode 100644 test/rspock/ast/comparison_to_assertion_transformation_test.rb create mode 100644 test/rspock/ast/parser/statement_parser_test.rb create mode 100644 test/rspock/ast/statement_to_assertion_transformation_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af05f2..975355d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,46 +1,154 @@ # Changelog + All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [UNRELEASED] +## [Unreleased] + +### Added + +- Spock-style implicit assertions: every non-assignment statement in Then/Expect blocks is now an assertion — no assertion API needed. +- Binary operator assertions: `=~`, `!~`, `>`, `<`, `>=`, `<=` with clear error messages. +- General statement assertions: bare boolean expressions (e.g. `obj.valid?`) with the original source text in the error message. +- Negation support: `!expr` is detected automatically and produces a clear error message. +- `>> raises(...)` syntax for exception stubbing in interactions. + +### Changed + +- Renamed `ConditionParser` to `StatementParser` and `ConditionToAssertionTransformation` to `StatementToAssertionTransformation` for consistency with Spock's model. +- Then and Expect block parsers now use `StatementParser` for statement classification. + +### Removed + +- `ComparisonToAssertionTransformation` — replaced by `StatementToAssertionTransformation`. + +## [2.3.1] - 2026-02-27 + +### Fixed + +- Require `block_capture` so it is available at runtime. + +## [2.3.0] - 2026-02-27 + +### Added + +- Interaction transformations and block identity verification via `&` operator. +- RSpock AST node hierarchy (`Node`, `InteractionNode`, `BodyNode`, etc.) for type-safe AST handling. +- `TestMethodParser` extracted from `TestMethodTransformation` for separation of parsing and transformation. + +### Changed + +- Restructured block classes into `Parser` namespace and converted `InteractionParser` to a class. +- Introduced `BodyNode` and removed legacy interaction transformations. + +## [2.2.0] - 2026-02-25 + +### Added + +- Interaction stubbing with `>>` for return value stubbing in Then block interactions. + +### Fixed + +- Pry and pry-byebug compatibility. +- Failing test on Ruby 3+. +- `filter_string` for `ast_transform` 2.1.4 source mapping change. + +## [2.1.0] - 2026-02-21 + +### Added + +- Ruby 4.0 support. + +### Fixed + +- Codecov badge URL to use master branch. + +## [2.0.0] - 2026-02-21 + +### Changed + +- Minimum Ruby version bumped to 3.2. +- Upgraded to Ruby 3.x compatibility. +- Use `ast_transform` 2.0.0 from RubyGems. +- CI modernization and release workflow improvements. + +## [1.0.0] - 2020-07-09 + ### Added -- Interaction-based testing: Mock with expectations in the Then block. + +- Interaction-based testing: mock with expectations in the Then block. +- Travis CI and code coverage. ### Changed + - Test names now have the test index and line number as suffix instead of prefix. -- Cleanup transformed code output. +- Removed unnecessary ensure block when Cleanup block is empty; moved source map wrapper to class scope. +- Bump `ast_transform` to release 1.0.0. ### Fixed -- Fixed source mapping for transformed assertion nodes. -## [0.2.5] 2019-05-28 +- Source mapping for transformed assertion nodes. +- Truth table generator command with proper escaping. + +## [0.2.5] - 2019-05-28 + ### Fixed -- Fixed BacktraceFilter so that source mapping works again -## [0.2.4] 2019-05-27 +- BacktraceFilter so that source mapping works again. + +## [0.2.4] - 2019-05-27 + ### Changed -- Bump Unparser dependency from ~> 0.2.8 to ~> 0.4 -## [0.2.3] 2018-11-09 +- Bump Unparser dependency from `~> 0.2.8` to `~> 0.4`. + +## [0.2.3] - 2018-11-09 + ### Fixed -- Cleanup block can now contain more than one node -## [0.2.2] 2018-11-08 +- Cleanup block can now contain more than one node. + +## [0.2.2] - 2018-11-08 + ### Changed -- Extracted ASTTransform to its own gem: `ast_transform` -## [0.2.1] 2018-10-09 +- Extracted ASTTransform to its own gem: `ast_transform`. + +## [0.2.1] - 2018-10-09 + ### Added -- _line_number_ is now displayed in the test name, and is available in test scope for debugging purposes + +- `_line_number_` is now displayed in the test name and available in test scope for debugging. ### Changed -- Renamed test_index to _test_index_ -## [0.2.0] 2018-09-21 +- Renamed `test_index` to `_test_index_`. + +## [0.2.0] - 2018-09-21 + ### Added + - Truth table generator Rake task. -## [0.1.1] 2018-09-19 -### Initial Release! \ No newline at end of file +## [0.1.1] - 2018-09-18 + +### Added + +- Initial release. + +[Unreleased]: https://github.com/rspockframework/rspock/compare/v2.3.1...HEAD +[2.3.1]: https://github.com/rspockframework/rspock/compare/v2.3.0...v2.3.1 +[2.3.0]: https://github.com/rspockframework/rspock/compare/v2.2.0...v2.3.0 +[2.2.0]: https://github.com/rspockframework/rspock/compare/v2.1.0...v2.2.0 +[2.1.0]: https://github.com/rspockframework/rspock/compare/v2.0.0...v2.1.0 +[2.0.0]: https://github.com/rspockframework/rspock/compare/1.0.0...v2.0.0 +[1.0.0]: https://github.com/rspockframework/rspock/compare/0.2.5...1.0.0 +[0.2.5]: https://github.com/rspockframework/rspock/compare/0.2.4...0.2.5 +[0.2.4]: https://github.com/rspockframework/rspock/compare/0.2.3...0.2.4 +[0.2.3]: https://github.com/rspockframework/rspock/compare/0.2.2...0.2.3 +[0.2.2]: https://github.com/rspockframework/rspock/compare/0.2.1...0.2.2 +[0.2.1]: https://github.com/rspockframework/rspock/compare/0.2.0...0.2.1 +[0.2.0]: https://github.com/rspockframework/rspock/compare/0.1.1...0.2.0 +[0.1.1]: https://github.com/rspockframework/rspock/releases/tag/0.1.1 diff --git a/README.md b/README.md index 7eebe95..349b626 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ Note: RSpock is heavily inspired by Spock for the Groovy programming language. * BDD-style code blocks: Given, When, Then, Expect, Cleanup, Where * Data-driven testing with incredibly expressive table-based Where blocks -* Expressive assertions: Use familiar comparison operators `==` and `!=` for assertions! +* Spock-style implicit assertions: every statement in Then/Expect blocks is an assertion (no assertion API needed) +* Binary operator assertions: `==`, `!=`, `=~`, `!~`, `>`, `<`, `>=`, `<=` with clear error messages +* General statement assertions: bare boolean expressions (e.g. `obj.valid?`, `list.include?(x)`) and negation (`!obj.empty?`) with source-text error messages * [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>`, [exception stubbing](#stubbing-exceptions) via `>> raises(...)`, and [block forwarding verification](#block-forwarding-verification) via `&block` * (Planned) BDD-style custom reporter that outputs information from Code Blocks * (Planned) Capture all Then block violations @@ -150,15 +152,25 @@ The When block describes the stimulus to be applied to the system under test. It ```ruby Then "The product is added to the cart" +!cart.products.empty? cart.products.size == 1 cart.products.first == product +cart.products.size > 0 ``` -The Then block describes the response from the stimulus. Any comparison operators used in the Then block (`==` or `!=`) is transformed to assert_equal / refute_equal under the hood. By convention, the __LHS__ operand is considered the __actual__ value, while the __RHS__ operand is considered the __expected__ value. +The Then block describes the response from the stimulus. Following Spock's core model, **every statement is an assertion** unless it's a variable assignment. No assertion API is needed. + +**Binary operators** (`==`, `!=`, `=~`, `!~`, `>`, `<`, `>=`, `<=`) produce clear error messages on failure. By convention, the **LHS** operand is the **actual** value and the **RHS** is the **expected** value. + +**General statements** (bare boolean expressions like `obj.valid?`, `list.include?(x)`) include the original source text in the error message, so you see exactly which expression failed. **Negation** (`!expr`) is detected automatically. + +**Variable assignments** pass through unchanged and execute in source order after the stimulus. #### Expect Block -The Expect block is useful when expressing the stimulus and the response in one statement is more natural. For example, let's compare two equivalent ways of describing some behaviour: +The Expect block is useful when expressing the stimulus and the response in one statement is more natural. The same assertion rules apply as in Then blocks — every statement is an assertion unless it's a variable assignment. + +For example, let's compare two equivalent ways of describing some behaviour: ##### When + Then ```ruby @@ -175,6 +187,17 @@ Expect "absolute of -2 is 2" -2.abs == 2 ``` +The Expect block supports the full range of assertion expressions: + +```ruby +Expect "string matching and predicates" +str =~ /potato/ +str.include?("pot") +!str.empty? +str.length > 3 +str.length == 6 +``` + A good rule of thumb is using When + Then blocks to describe methods with side-effects and Expect blocks to describe purely functional methods. #### Cleanup Block @@ -560,20 +583,24 @@ To install this gem onto your local machine, run `bundle exec rake install`. ## Releasing a New Version -There are two ways to create a release. Both require that `version.rb` has already been updated and merged to main. +There are two ways to create a release. Both require that `version.rb` has already been updated, `CHANGELOG.md` has been updated, and changes have been merged to main. ### Via GitHub UI -1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main -2. Go to the repo on GitHub → **Releases** → **Draft a new release** -3. Enter a new tag (e.g. `v2.0.0`), select `main` as the target branch -4. Add a title and release notes (GitHub can auto-generate these from merged PRs) -5. Click **Publish release** +1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock` +2. Move the `[Unreleased]` section in `CHANGELOG.md` to a new version heading with today's date (e.g. `## [2.4.0] - 2026-03-01`) and add a fresh empty `[Unreleased]` section above it. Update the comparison links at the bottom of the file. +3. Commit, open a PR, and merge to main +4. Go to the repo on GitHub → **Releases** → **Draft a new release** +5. Enter a new tag (e.g. `v2.4.0`), select `main` as the target branch +6. Add a title and release notes (GitHub can auto-generate these from merged PRs) +7. Click **Publish release** ### Via CLI -1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main -2. Tag and push: +1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock` +2. Move the `[Unreleased]` section in `CHANGELOG.md` to a new version heading with today's date and add a fresh empty `[Unreleased]` section above it. Update the comparison links at the bottom of the file. +3. Commit, open a PR, and merge to main +4. Tag and push: ``` git checkout main && git pull git tag v2.0.0 diff --git a/lib/rspock/ast/comparison_to_assertion_transformation.rb b/lib/rspock/ast/comparison_to_assertion_transformation.rb deleted file mode 100644 index eccb218..0000000 --- a/lib/rspock/ast/comparison_to_assertion_transformation.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/abstract_transformation' -require 'rspock/ast/method_call_to_lvar_transformation' - -module RSpock - module AST - class ComparisonToAssertionTransformation < ASTTransform::AbstractTransformation - def initialize(*ignored_method_call_symbols) - @method_call_transformation = RSpock::AST::MethodCallToLVarTransformation.new(*ignored_method_call_symbols) - end - - def on_send(node) - if node.children.count == 3 && node.children[1] == :== && ignored_method_call_node?(node) - transform_to_assert_equal(node) - elsif node.children.count == 3 && node.children[1] == :!= && ignored_method_call_node?(node) - transform_to_refute_equal(node) - else - node.updated(nil, process_all(node)) - end - end - - private - - def ignored_method_call_node?(node) - return false unless node.is_a?(::Parser::AST::Node) - - !@method_call_transformation.method_call_node?(node.children[0]) && - !@method_call_transformation.method_call_node?(node.children[2]) - end - - def transform_to_assert_equal(node) - node.updated(nil, [nil, :assert_equal, node.children[2], node.children[0]]) - end - - def transform_to_refute_equal(node) - node.updated(nil, [nil, :refute_equal, node.children[2], node.children[0]]) - end - end - end -end diff --git a/lib/rspock/ast/node.rb b/lib/rspock/ast/node.rb index a9a4fcc..c9e6893 100644 --- a/lib/rspock/ast/node.rb +++ b/lib/rspock/ast/node.rb @@ -93,6 +93,21 @@ def outcome = children[4] def block_pass = children[5] end + class BinaryStatementNode < Node + register :rspock_binary_statement + + def lhs = children[0] + def operator = children[1] + def rhs = children[2] + end + + class StatementNode < Node + register :rspock_statement + + def expression = children[0] + def source = children[1] + end + module NodeBuilder include ASTTransform::TransformationHelper diff --git a/lib/rspock/ast/parser/expect_block.rb b/lib/rspock/ast/parser/expect_block.rb index 782813a..3eb4e0f 100644 --- a/lib/rspock/ast/parser/expect_block.rb +++ b/lib/rspock/ast/parser/expect_block.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require 'rspock/ast/parser/block' +require 'rspock/ast/parser/statement_parser' module RSpock module AST @@ -20,6 +21,12 @@ def can_end? def successors @successors ||= [:Cleanup, :Where].freeze end + + def to_rspock_node + statement_parser = StatementParser.new + spock_children = @children.map { |child| statement_parser.parse(child) } + s(:rspock_expect, *spock_children) + end end end end diff --git a/lib/rspock/ast/parser/statement_parser.rb b/lib/rspock/ast/parser/statement_parser.rb new file mode 100644 index 0000000..a512040 --- /dev/null +++ b/lib/rspock/ast/parser/statement_parser.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +require 'rspock/ast/node' + +module RSpock + module AST + module Parser + # Classifies raw Ruby AST statements into RSpock node types for Then/Expect blocks. + # + # - Assignments pass through as raw AST (no wrapping). + # - Binary operators (==, !=, =~, etc.) become :rspock_binary_statement nodes. + # - Everything else becomes :rspock_statement nodes with the original source text captured. + class StatementParser + include RSpock::AST::NodeBuilder + + BINARY_OPERATORS = %i[== != =~ !~ > < >= <=].freeze + ASSIGNMENT_TYPES = %i[lvasgn masgn op_asgn or_asgn and_asgn].freeze + + def parse(node) + return node if assignment?(node) + return build_binary_statement(node) if binary_statement?(node) + + build_statement(node) + end + + private + + def assignment?(node) + ASSIGNMENT_TYPES.include?(node.type) + end + + def binary_statement?(node) + node.type == :send && + node.children.length == 3 && + BINARY_OPERATORS.include?(node.children[1]) + end + + def build_binary_statement(node) + s(:rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2]) + end + + def build_statement(node) + source = node.loc&.expression&.source || node.inspect + s(:rspock_statement, node, s(:str, source)) + end + end + end + end +end diff --git a/lib/rspock/ast/parser/then_block.rb b/lib/rspock/ast/parser/then_block.rb index 82fa54b..2b3b9be 100644 --- a/lib/rspock/ast/parser/then_block.rb +++ b/lib/rspock/ast/parser/then_block.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rspock/ast/parser/block' require 'rspock/ast/parser/interaction_parser' +require 'rspock/ast/parser/statement_parser' module RSpock module AST @@ -19,8 +20,16 @@ def successors end def to_rspock_node - parser = InteractionParser.new - spock_children = @children.map { |child| parser.parse(child) } + interaction_parser = InteractionParser.new + statement_parser = StatementParser.new + + spock_children = @children.map do |child| + parsed = interaction_parser.parse(child) + next parsed unless parsed.equal?(child) + + statement_parser.parse(child) + end + s(:rspock_then, *spock_children) end end diff --git a/lib/rspock/ast/statement_to_assertion_transformation.rb b/lib/rspock/ast/statement_to_assertion_transformation.rb new file mode 100644 index 0000000..f73dac9 --- /dev/null +++ b/lib/rspock/ast/statement_to_assertion_transformation.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +require 'rspock/ast/node' + +module RSpock + module AST + # Transforms :rspock_binary_statement and :rspock_statement nodes into Minitest assertion calls. + # + # Binary statements dispatch to specialized assertions (assert_equal, assert_match, assert_operator). + # General statements use assert_equal(true/false, expr, source_message) with negation detection. + class StatementToAssertionTransformation + include RSpock::AST::NodeBuilder + + BINARY_DISPATCH = { + :== => :assert_equal, + :!= => :refute_equal, + :=~ => :assert_match, + :'!~' => :refute_match, + }.freeze + + OPERATOR_ASSERTIONS = %i[> < >= <=].freeze + + def run(node) + case node.type + when :rspock_binary_statement + transform_binary_statement(node) + when :rspock_statement + transform_statement(node) + else + node + end + end + + private + + def transform_binary_statement(node) + lhs = node.lhs + op = node.operator.children[0] + rhs = node.rhs + + if (assertion = BINARY_DISPATCH[op]) + s(:send, nil, assertion, rhs, lhs) + elsif OPERATOR_ASSERTIONS.include?(op) + s(:send, nil, :assert_operator, lhs, s(:sym, op), rhs) + else + s(:send, nil, :assert_operator, lhs, s(:sym, op), rhs) + end + end + + def transform_statement(node) + expr = node.expression + source_text = node.source.children[0] + + if negated?(expr) + inner = expr.children[0] + message = "Expected \"#{source_text}\" to be false" + s(:send, nil, :assert_equal, s(:false), inner, s(:str, message)) + else + message = "Expected \"#{source_text}\" to be true" + s(:send, nil, :assert_equal, s(:true), expr, s(:str, message)) + end + end + + def negated?(node) + node.type == :send && node.children[1] == :! && node.children.length == 2 + end + end + end +end diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index 660780c..3564938 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'ast_transform/abstract_transformation' require 'rspock/ast/node' -require 'rspock/ast/comparison_to_assertion_transformation' +require 'rspock/ast/statement_to_assertion_transformation' require 'rspock/ast/header_nodes_transformation' require 'rspock/ast/interaction_to_mocha_mock_transformation' require 'rspock/ast/interaction_to_block_identity_assertion_transformation' @@ -14,7 +14,7 @@ module AST class TestMethodTransformation < ASTTransform::AbstractTransformation def initialize(block_registry, strict: true) @parser = Parser::TestMethodParser.new(block_registry, strict: strict) - @comparison_transformation = ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_) + @statement_transformation = StatementToAssertionTransformation.new end def run(node) @@ -59,7 +59,7 @@ def transform_then_block(then_node, hoisted_setups) interaction_setups << setup then_children << assertion unless assertion.equal?(child) else - then_children << @comparison_transformation.run(child) + then_children << transform_statement_or_passthrough(child) end end @@ -77,10 +77,19 @@ def transform_then_block(then_node, hoisted_setups) end def transform_expect_block(expect_node) - new_children = expect_node.children.map { |child| @comparison_transformation.run(child) } + new_children = expect_node.children.map { |child| transform_statement_or_passthrough(child) } expect_node.updated(nil, new_children) end + def transform_statement_or_passthrough(child) + case child.type + when :rspock_binary_statement, :rspock_statement + @statement_transformation.run(child) + else + child + end + end + # --- Build final Ruby AST --- def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups) diff --git a/test/example_rspock_test.rb b/test/example_rspock_test.rb index 8a93632..3ba951e 100644 --- a/test/example_rspock_test.rb +++ b/test/example_rspock_test.rb @@ -65,6 +65,64 @@ def mul(a, b) Cleanup end + # --- Statement assertions --- + + test "regex match with =~ in Expect" do + Expect + "hello potato world" =~ /potato/ + end + + test "comparison operators in Then with #{a}" do + Given + stack = [] + + When + stack.push(a) + + Then + stack.size == 1 + stack.size > 0 + stack.size >= 1 + stack.size < 2 + stack.size <= 1 + + Where + a + "potato" + "tomato" + end + + test "bare boolean expressions in Expect" do + Expect + [1, 2, 3].include?(2) + "hello".is_a?(String) + "hello".respond_to?(:length) + end + + test "negation in Then" do + Given + stack = [1] + + When + stack.push(2) + + Then + !stack.empty? + !stack.nil? + end + + test "variable assignment in Then does not break" do + Given + stack = [] + + When + stack.push("item") + + Then + result = stack.first + result == "item" + end + test "interactions" do Given dep = mock diff --git a/test/rspock/ast/comparison_to_assertion_transformation_test.rb b/test/rspock/ast/comparison_to_assertion_transformation_test.rb deleted file mode 100644 index 8cc4fdb..0000000 --- a/test/rspock/ast/comparison_to_assertion_transformation_test.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'rspock/ast/comparison_to_assertion_transformation' - -module RSpock - module AST - class ComparisonToAssertionTransformationTest < Minitest::Test - extend RSpock::Declarative - include ASTTransform::TransformationHelper - - def setup - @transformation = RSpock::AST::ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_) - @equal_ast = s(:send, 1, :==, 2) - @not_equal_ast = s(:send, 1, :!=, 2) - end - - test "#run returns nil when passing nil" do - actual = @transformation.run(nil) - - assert_nil actual - end - - test "#run returns input when not an AST node" do - actual = @transformation.run(123) - - assert_equal 123, actual - end - - test "#on_send transforms AST into assert_equal when using == where op1 is actual and op2 is expected" do - actual = @transformation.on_send(@equal_ast) - expected = s(:send, nil, :assert_equal, 2, 1) - - assert_equal expected, actual - end - - test "#on_send transforms AST into assert_equal when using != where op1 is actual and op2 is expected" do - actual = @transformation.on_send(@not_equal_ast) - expected = s(:send, nil, :refute_equal, 2, 1) - - assert_equal expected, actual - end - - test "#on_send applies transformation for nested comparisons" do - node = s(:block, @equal_ast) - - actual = @transformation.on_send(node) - expected = s(:block, s(:send, nil, :assert_equal, 2, 1)) - - assert_equal expected, actual - end - - test "#on_send returns the same AST if it does not contain a comparison" do - node = s(:block, s(:send, nil, :assert_equal, 2, 1)) - - actual = @transformation.on_send(node) - expected = s(:block, s(:send, nil, :assert_equal, 2, 1)) - - assert_equal expected, actual - end - - test "#on_send does not transform the AST into assert_equal if lhs is _test_index_" do - node = s(:send, nil, s(:send, nil, :_test_index_), :==, 1) - - actual = @transformation.on_send(node) - - assert_equal node, actual - end - - test "#on_send does not transform the AST into assert_equal if rhs is _test_index_" do - node = s(:send, nil, 1, :==, s(:send, nil, :_test_index_)) - - actual = @transformation.on_send(node) - - assert_equal node, actual - end - - test "#on_send does not transform the AST into assert_equal if lhs is _line_number_" do - node = s(:send, nil, s(:send, nil, :_line_number_), :==, 1) - - actual = @transformation.on_send(node) - - assert_equal node, actual - end - - test "#on_send does not transform the AST into assert_equal if rhs is _line_number_" do - node = s(:send, nil, 1, :==, s(:send, nil, :_line_number_)) - - actual = @transformation.on_send(node) - - assert_equal node, actual - end - end - end -end diff --git a/test/rspock/ast/parser/expect_block_test.rb b/test/rspock/ast/parser/expect_block_test.rb index 993f366..14f70fc 100644 --- a/test/rspock/ast/parser/expect_block_test.rb +++ b/test/rspock/ast/parser/expect_block_test.rb @@ -41,13 +41,15 @@ def setup assert_equal [comparison], actual end - test "#to_rspock_node returns :rspock_expect node with children" do + test "#to_rspock_node returns :rspock_expect node with statement children" do @block << s(:send, 1, :==, 2) @block << s(:send, 1, :!=, 2) ir = @block.to_rspock_node assert_equal :rspock_expect, ir.type assert_equal 2, ir.children.length + assert_equal :rspock_binary_statement, ir.children[0].type + assert_equal :rspock_binary_statement, ir.children[1].type end end end diff --git a/test/rspock/ast/parser/statement_parser_test.rb b/test/rspock/ast/parser/statement_parser_test.rb new file mode 100644 index 0000000..baec9ed --- /dev/null +++ b/test/rspock/ast/parser/statement_parser_test.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/statement_parser' + +module RSpock + module AST + module Parser + class StatementParserTest < Minitest::Test + extend RSpock::Declarative + include ASTTransform::TransformationHelper + + def setup + @transformer = ASTTransform::Transformer.new + @parser = StatementParser.new + end + + # --- Assignments pass through --- + + test "#parse returns lvasgn node unchanged" do + ast = build_ast('x = 1') + result = @parser.parse(ast) + + assert_equal ast, result + assert_equal :lvasgn, result.type + end + + test "#parse returns masgn node unchanged" do + ast = build_ast('a, b = 1, 2') + result = @parser.parse(ast) + + assert_equal ast, result + assert_equal :masgn, result.type + end + + test "#parse returns op_asgn node unchanged" do + ast = build_ast('x += 1') + result = @parser.parse(ast) + + assert_equal ast, result + assert_equal :op_asgn, result.type + end + + test "#parse returns or_asgn node unchanged" do + ast = build_ast('x ||= 1') + result = @parser.parse(ast) + + assert_equal ast, result + assert_equal :or_asgn, result.type + end + + test "#parse returns and_asgn node unchanged" do + ast = build_ast('x &&= 1') + result = @parser.parse(ast) + + assert_equal ast, result + assert_equal :and_asgn, result.type + end + + # --- Binary operators --- + + test "#parse wraps == in :rspock_binary_statement" do + ast = build_ast('a == b') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :==), result.operator + end + + test "#parse wraps != in :rspock_binary_statement" do + ast = build_ast('a != b') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :!=), result.operator + end + + test "#parse wraps =~ in :rspock_binary_statement" do + ast = build_ast('a =~ /foo/') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :=~), result.operator + end + + test "#parse wraps !~ in :rspock_binary_statement" do + ast = build_ast('a !~ /foo/') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :'!~'), result.operator + end + + test "#parse wraps > in :rspock_binary_statement" do + ast = build_ast('a > b') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :>), result.operator + end + + test "#parse wraps < in :rspock_binary_statement" do + ast = build_ast('a < b') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :<), result.operator + end + + test "#parse wraps >= in :rspock_binary_statement" do + ast = build_ast('a >= b') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :>=), result.operator + end + + test "#parse wraps <= in :rspock_binary_statement" do + ast = build_ast('a <= b') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:sym, :<=), result.operator + end + + test "#parse extracts lhs and rhs for binary statement" do + ast = build_ast('a == 42') + result = @parser.parse(ast) + + assert_equal :rspock_binary_statement, result.type + assert_equal s(:send, nil, :a), result.lhs + assert_equal s(:int, 42), result.rhs + end + + # --- General statements --- + + test "#parse wraps method call in :rspock_statement" do + ast = build_ast('obj.valid?') + result = @parser.parse(ast) + + assert_equal :rspock_statement, result.type + assert_equal ast, result.expression + end + + test "#parse wraps negated expression in :rspock_statement" do + ast = build_ast('!obj.empty?') + result = @parser.parse(ast) + + assert_equal :rspock_statement, result.type + assert_equal ast, result.expression + end + + test "#parse captures source text in :rspock_statement" do + ast = build_ast('obj.valid?') + result = @parser.parse(ast) + + assert_equal :str, result.source.type + assert_equal 'obj.valid?', result.source.children[0] + end + + test "#parse captures source text for negated expression" do + ast = build_ast('!obj.empty?') + result = @parser.parse(ast) + + assert_equal '!obj.empty?', result.source.children[0] + end + + test "#parse wraps bare identifier in :rspock_statement" do + ast = build_ast('result') + result = @parser.parse(ast) + + assert_equal :rspock_statement, result.type + end + + # --- Does not classify non-binary sends as binary --- + + test "#parse does not treat method call with args as binary statement" do + ast = build_ast('obj.include?("foo")') + result = @parser.parse(ast) + + assert_equal :rspock_statement, result.type + end + + private + + def build_ast(source) + @transformer.build_ast(source) + end + end + end + end +end diff --git a/test/rspock/ast/parser/then_block_test.rb b/test/rspock/ast/parser/then_block_test.rb index 3f86f77..3140a23 100644 --- a/test/rspock/ast/parser/then_block_test.rb +++ b/test/rspock/ast/parser/then_block_test.rb @@ -50,7 +50,7 @@ def setup ir = @block.to_rspock_node assert_equal :rspock_then, ir.type assert_equal 1, ir.children.length - assert_equal :send, ir.children[0].type + assert_equal :rspock_binary_statement, ir.children[0].type end test "#to_rspock_node converts interaction nodes to :rspock_interaction" do @@ -63,12 +63,15 @@ def setup assert_equal :rspock_interaction, ir.children[0].type end - test "#to_rspock_node preserves non-interaction children unchanged" do - comparison = s(:send, 1, :==, 2) - @block << comparison + test "#to_rspock_node wraps comparison children as binary statements" do + @block << s(:send, 1, :==, 2) ir = @block.to_rspock_node - assert_equal comparison, ir.children[0] + child = ir.children[0] + assert_equal :rspock_binary_statement, child.type + assert_equal 1, child.lhs + assert_equal s(:sym, :==), child.operator + assert_equal 2, child.rhs end test "#to_rspock_node parses interaction with correct structure" do @@ -129,7 +132,7 @@ def setup ir = @block.to_rspock_node assert_equal 2, ir.children.length - assert_equal :send, ir.children[0].type + assert_equal :rspock_binary_statement, ir.children[0].type assert_equal :rspock_interaction, ir.children[1].type end diff --git a/test/rspock/ast/statement_to_assertion_transformation_test.rb b/test/rspock/ast/statement_to_assertion_transformation_test.rb new file mode 100644 index 0000000..3d20084 --- /dev/null +++ b/test/rspock/ast/statement_to_assertion_transformation_test.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/statement_to_assertion_transformation' + +module RSpock + module AST + class StatementToAssertionTransformationTest < Minitest::Test + extend RSpock::Declarative + include ASTTransform::TransformationHelper + + def setup + @transformation = StatementToAssertionTransformation.new + end + + # --- Binary statement: == --- + + test "#run transforms == into assert_equal(rhs, lhs)" do + node = build_binary(:==, s(:send, nil, :actual), s(:int, 42)) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :assert_equal, s(:int, 42), s(:send, nil, :actual)), result + end + + # --- Binary statement: != --- + + test "#run transforms != into refute_equal(rhs, lhs)" do + node = build_binary(:!=, s(:send, nil, :actual), s(:int, 42)) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :refute_equal, s(:int, 42), s(:send, nil, :actual)), result + end + + # --- Binary statement: =~ --- + + test "#run transforms =~ into assert_match(rhs, lhs)" do + regex = s(:regexp, s(:str, "foo"), s(:regopt)) + node = build_binary(:=~, s(:send, nil, :str), regex) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :assert_match, regex, s(:send, nil, :str)), result + end + + # --- Binary statement: !~ --- + + test "#run transforms !~ into refute_match(rhs, lhs)" do + regex = s(:regexp, s(:str, "foo"), s(:regopt)) + node = build_binary(:'!~', s(:send, nil, :str), regex) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :refute_match, regex, s(:send, nil, :str)), result + end + + # --- Binary statement: comparison operators --- + + test "#run transforms > into assert_operator(lhs, :>, rhs)" do + node = build_binary(:>, s(:send, nil, :a), s(:int, 5)) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :assert_operator, s(:send, nil, :a), s(:sym, :>), s(:int, 5)), result + end + + test "#run transforms < into assert_operator(lhs, :<, rhs)" do + node = build_binary(:<, s(:send, nil, :a), s(:int, 5)) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :assert_operator, s(:send, nil, :a), s(:sym, :<), s(:int, 5)), result + end + + test "#run transforms >= into assert_operator(lhs, :>=, rhs)" do + node = build_binary(:>=, s(:send, nil, :a), s(:int, 5)) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :assert_operator, s(:send, nil, :a), s(:sym, :>=), s(:int, 5)), result + end + + test "#run transforms <= into assert_operator(lhs, :<=, rhs)" do + node = build_binary(:<=, s(:send, nil, :a), s(:int, 5)) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :assert_operator, s(:send, nil, :a), s(:sym, :<=), s(:int, 5)), result + end + + # --- Binary statement: unknown operator fallback --- + + test "#run falls back to assert_operator for unrecognized binary operators" do + node = build_binary(:**, s(:send, nil, :a), s(:int, 2)) + + result = @transformation.run(node) + + assert_equal s(:send, nil, :assert_operator, s(:send, nil, :a), s(:sym, :**), s(:int, 2)), result + end + + # --- General statement --- + + test "#run transforms general statement into assert_equal(true, expr, message)" do + expr = s(:send, s(:send, nil, :obj), :valid?) + node = build_statement(expr, "obj.valid?") + + result = @transformation.run(node) + + expected = s(:send, nil, :assert_equal, + s(:true), + expr, + s(:str, 'Expected "obj.valid?" to be true') + ) + assert_equal expected, result + end + + # --- General statement: negation --- + + test "#run transforms negated statement into assert_equal(false, inner, message)" do + inner = s(:send, s(:send, nil, :obj), :empty?) + negated = s(:send, inner, :!) + node = build_statement(negated, "!obj.empty?") + + result = @transformation.run(node) + + expected = s(:send, nil, :assert_equal, + s(:false), + inner, + s(:str, 'Expected "!obj.empty?" to be false') + ) + assert_equal expected, result + end + + # --- Passthrough --- + + test "#run returns unknown node types unchanged" do + node = s(:lvasgn, :x, s(:int, 1)) + + result = @transformation.run(node) + + assert_equal node, result + end + + private + + def build_binary(op, lhs, rhs) + RSpock::AST::Node.build(:rspock_binary_statement, lhs, s(:sym, op), rhs) + end + + def build_statement(expr, source_text) + RSpock::AST::Node.build(:rspock_statement, expr, s(:str, source_text)) + end + end + end +end diff --git a/test/transformation_helper.rb b/test/transformation_helper.rb index 2a2dcbc..de48145 100644 --- a/test/transformation_helper.rb +++ b/test/transformation_helper.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -require 'rspock/ast/comparison_to_assertion_transformation' require 'ast_transform/transformer' require 'ast_transform/transformation_helper' require 'string_helper'