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'