diff --git a/README.md b/README.md index 14ef4ac..72d813a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ 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! -* [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>` +* [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>` 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 @@ -306,13 +306,14 @@ test "#publish sends a message to all subscribers" do end ``` -The above ___Then___ block contains 2 interactions, each of which has 4 parts: the _cardinality_, the _receiver_, the _message_ and its _arguments_. Optionally, a _return value_ can be specified using the `>>` operator. +The above ___Then___ block contains 2 interactions, each of which has 4 parts: the _cardinality_, the _receiver_, the _message_ and its _arguments_. Optionally, a _return value_ can be specified using the `>>` operator, and _block forwarding_ can be verified using the `&` operator. ``` -1 * receiver.message('hello') >> "result" -| | | | | -| | | | return value (optional) -| | | argument(s) +1 * receiver.message('hello', &blk) >> "result" +| | | | | | +| | | | | return value (optional) +| | | | block forwarding (optional) +| | | argument(s) (optional) | | message | receiver cardinality @@ -320,6 +321,16 @@ cardinality Note: Interactions are supported in the ___Then___ block only. +#### Execution Order + +Although interactions are _declared_ in the ___Then___ block, they are effectively active _before_ the ___When___ block executes. RSpock ensures the following order: + +1. **Before the stimulus** — mock expectations and block captures are installed on the receiver, so they are ready to intercept calls. +2. **Stimulus** — the ___When___ block runs. +3. **After the stimulus** — assertions such as block identity checks run alongside other ___Then___ assertions. Cardinality is verified at teardown. + +Simply declare _what_ should happen in a natural order — RSpock handles the _when_. + #### Cardinality The cardinality of an interaction describes how often a method is expected to be called. It can be a fixed number of times, or a range. @@ -410,6 +421,25 @@ _ * cache.fetch("key") >> expensive_result # a variable **Note**: Without `>>`, an interaction sets up an expectation only (the method will return `nil` by default). Use `>>` when the code under test depends on the return value. +#### Block Forwarding Verification + +When the code under test forwards a block (or proc) to a collaborator, you may want to verify that the _exact_ block was passed through. RSpock supports this with the `&` operator in interactions, performing an identity check on the block reference. + +```ruby +test "#frame forwards the block to CLI::UI.frame" do + Given "a block to forward" + my_block = proc { puts "hello" } + + When "we call frame with that block" + @ui.frame("Build", &my_block) + + Then "the block was forwarded to cli_ui.frame" + 1 * @cli_ui.frame("Build", to: @out, &my_block) +end +``` + +**Note**: Inline blocks (`do...end` or `{ }`) are not supported in interactions and will raise an `InteractionError`. Use a named proc or lambda variable with `&` instead, since block forwarding verification requires a reference to compare against. + ## Debugging ### Pry diff --git a/lib/rspock/ast/block.rb b/lib/rspock/ast/block.rb deleted file mode 100644 index 162728c..0000000 --- a/lib/rspock/ast/block.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true -module RSpock - module AST - class BlockError < StandardError; end - - class Block - # Constructs a new Block. - # - # @param type [Symbol] The Block type. - # @param node [Parser::AST::Node] The node associated to this Block. - def initialize(type, node) - @type = type - @node = node - @children = [] - @node_container = true - end - - attr_reader :type, :node - - # Adds the given +child_node+ to this Block. - # - # @param child_node [Parser::AST::Node] The node to be added. - # - # @raise [BlockError] if this Block cannot contain other nodes. - def <<(child_node) - raise BlockError, succession_error_msg unless node_container? - - @children << child_node - end - - # Adds the given +child_node+ to the beginning of this Block. - # - # @param child_node [Parser::AST::Node] The node to be added. - # - # @raise [BlockError] if this Block cannot contain other nodes. - def unshift(child_node) - raise BlockError, succession_error_msg unless node_container? - - @children.unshift(child_node) - end - - # Checks whether this Block can contain other nodes. - # - # @return [Boolean] True if this Block can contain other nodes, false otherwise. - def node_container? - @node_container - end - - # Sets whether this Block can contain other nodes. - # - # @param value [Boolean] True if this Block can contain other nodes, false otherwise. - def node_container=(value) - @node_container = value - end - - # Retrieves the Parser::Source::Range for this Block. - # - # @return [Parser::Source::Range] The range. - def range - node&.loc&.expression || "?" - end - - # Retrieves the valid successors for this Block. - # Note: Defaults to [:End]. - # - # @return [Array] This Block's successors. - def successors - @successors ||= [:End].freeze - end - - # Retrieves the duped array of children AST nodes for this Block. - # - # @return [Array] The children nodes. - def children - @children.dup - end - - # Checks whether or not the given +block+ is a valid successor for this Block. - # - # @param block [Block] The candidate successor. - # - # @return [Boolean] True if the given block is a valid successor, false otherwise. - def valid_successor?(block) - successors.include?(block.type) - end - - # Retrieves the error message for succession errors. - # - # @return [String] The error message. - def succession_error_msg - "Block #{type} @ #{range} must be followed by one of these Blocks: #{successors}" - end - end - end -end diff --git a/lib/rspock/ast/cleanup_block.rb b/lib/rspock/ast/cleanup_block.rb deleted file mode 100644 index 61ea5fe..0000000 --- a/lib/rspock/ast/cleanup_block.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' - -module RSpock - module AST - class CleanupBlock < Block - def initialize(node) - super(:Cleanup, node) - end - - def successors - @successors ||= [:Where, :End].freeze - end - end - end -end diff --git a/lib/rspock/ast/comparison_to_assertion_transformation.rb b/lib/rspock/ast/comparison_to_assertion_transformation.rb index 055e434..eccb218 100644 --- a/lib/rspock/ast/comparison_to_assertion_transformation.rb +++ b/lib/rspock/ast/comparison_to_assertion_transformation.rb @@ -22,7 +22,7 @@ def on_send(node) private def ignored_method_call_node?(node) - return false unless node.is_a?(Parser::AST::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]) diff --git a/lib/rspock/ast/end_block.rb b/lib/rspock/ast/end_block.rb deleted file mode 100644 index 3905f17..0000000 --- a/lib/rspock/ast/end_block.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' - -module RSpock - module AST - class EndBlock < Block - def initialize - super(:End, nil) - @node_container = false - end - - def successors - @successors ||= [].freeze - end - end - end -end diff --git a/lib/rspock/ast/expect_block.rb b/lib/rspock/ast/expect_block.rb deleted file mode 100644 index a6917af..0000000 --- a/lib/rspock/ast/expect_block.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' -require 'rspock/ast/comparison_to_assertion_transformation' - -module RSpock - module AST - class ExpectBlock < Block - def initialize(node) - super(:Expect, node) - end - - def successors - @successors ||= [:Cleanup, :Where, :End].freeze - end - - def children - super.map { |child| ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_).run(child) } - end - end - end -end diff --git a/lib/rspock/ast/given_block.rb b/lib/rspock/ast/given_block.rb deleted file mode 100644 index d2fd2bc..0000000 --- a/lib/rspock/ast/given_block.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' - -module RSpock - module AST - class GivenBlock < Block - def initialize(node) - super(:Given, node) - end - - def successors - @successors ||= [:When, :Expect].freeze - end - end - end -end diff --git a/lib/rspock/ast/interaction_to_block_identity_assertion_transformation.rb b/lib/rspock/ast/interaction_to_block_identity_assertion_transformation.rb new file mode 100644 index 0000000..c46b301 --- /dev/null +++ b/lib/rspock/ast/interaction_to_block_identity_assertion_transformation.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +require 'ast_transform/abstract_transformation' +require 'rspock/ast/node' + +module RSpock + module AST + # Transforms an :rspock_interaction node into an assert_same assertion + # when a block_pass (&var) is present. + # + # Returns the node unchanged (passthrough) when no block_pass is present. + class InteractionToBlockIdentityAssertionTransformation < ASTTransform::AbstractTransformation + def initialize(index = 0) + @index = index + end + + def run(interaction) + return interaction unless interaction.type == :rspock_interaction + return interaction unless interaction.block_pass + + capture_var = :"__rspock_blk_#{@index}" + block_var = interaction.block_pass.children[0] + + s(:send, nil, :assert_same, + block_var, + s(:send, s(:lvar, capture_var), :call) + ) + end + end + end +end diff --git a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb new file mode 100644 index 0000000..7f9ba08 --- /dev/null +++ b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true +require 'ast_transform/abstract_transformation' +require 'rspock/ast/node' + +module RSpock + module AST + # Transforms an :rspock_interaction node into Mocha mock setup code. + # + # Input: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass) + # Output: receiver.expects(:message).with(*args).times(n).returns(value) + # + # When block_pass is present, wraps the expects chain with a BlockCapture.capture call. + class InteractionToMochaMockTransformation < ASTTransform::AbstractTransformation + def initialize(index = 0) + @index = index + end + + def run(interaction) + return interaction unless interaction.type == :rspock_interaction + + result = chain_call(interaction.receiver, :expects, s(:sym, interaction.message)) + result = chain_call(result, :with, *interaction.args.children) if interaction.args + result = build_cardinality(result, interaction.cardinality) + result = chain_call(result, :returns, interaction.return_value) if interaction.return_value + + if interaction.block_pass + build_block_capture_setup(result, interaction.receiver, interaction.message) + else + result + end + end + + private + + def build_cardinality(result, cardinality) + if any_matcher_node?(cardinality) + chain_call(result, :at_least, s(:int, 0)) + elsif [:send, :lvar, :int].include?(cardinality.type) + chain_call(result, :times, cardinality) + elsif cardinality.type == :begin && cardinality.children[0]&.type == :irange + min_node, max_node = cardinality.children[0].children + build_irange(result, min_node, max_node) + elsif cardinality.type == :begin && cardinality.children[0]&.type == :erange + min_node, max_node = cardinality.children[0].children + max_node = chain_call(max_node, :-, s(:int, 1)) + build_erange(result, min_node, max_node) + else + raise ArgumentError, "Unrecognized cardinality in :rspock_interaction: #{cardinality.type}" + end + end + + def build_irange(result, min_node, max_node) + if any_matcher_node?(min_node) && any_matcher_node?(max_node) + chain_call(result, :at_least, s(:int, 0)) + elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node) + chain_call(result, :at_least, min_node) + elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node) + result = chain_call(result, :at_least, s(:int, 0)) + chain_call(result, :at_most, max_node) + else + result = chain_call(result, :at_least, min_node) + chain_call(result, :at_most, max_node) + end + end + + def build_erange(result, min_node, max_node) + if any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0]) + chain_call(result, :at_least, s(:int, 0)) + elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0]) + chain_call(result, :at_least, min_node) + elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node.children[0]) + result = chain_call(result, :at_least, s(:int, 0)) + chain_call(result, :at_most, max_node) + else + result = chain_call(result, :at_least, min_node) + chain_call(result, :at_most, max_node) + end + end + + def build_block_capture_setup(expects_node, receiver, message) + capture_var = :"__rspock_blk_#{@index}" + + capture_call = s(:lvasgn, capture_var, + s(:send, + s(:const, s(:const, s(:const, nil, :RSpock), :Helpers), :BlockCapture), + :capture, + receiver, + s(:sym, message) + ) + ) + + s(:begin, expects_node, capture_call) + end + + def chain_call(receiver_node, method_name, *arg_nodes) + s(:send, receiver_node, method_name, *arg_nodes) + end + + def any_matcher_node?(node) + node.type == :send && node.children[0].nil? && node.children[1] == :_ + end + end + end +end diff --git a/lib/rspock/ast/interaction_transformation.rb b/lib/rspock/ast/interaction_transformation.rb deleted file mode 100644 index e4ffb01..0000000 --- a/lib/rspock/ast/interaction_transformation.rb +++ /dev/null @@ -1,165 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/abstract_transformation' - -module RSpock - module AST - class InteractionTransformation < ASTTransform::AbstractTransformation - class InteractionError < RuntimeError; end - - def run(node) - return node unless interaction_node?(node) - - parse_node(node) - transform_node - end - - def interaction_node?(node) - return false if node.nil? - return true if node.type == :send && node.children[1] == :* - return true if return_value_node?(node) - - false - end - - private - - ALLOWED_NODES = [:send, :lvar, :int] - private_constant(:ALLOWED_NODES) - - def transform_node - result = chain_call(@receiver_node, :expects, s(:sym, @message)) - result = chain_call(result, :with, *@arg_nodes) unless @arg_nodes.empty? - - if any_matcher_node?(@times_node) - result = chain_call(result, :at_least, s(:int, 0)) - elsif ALLOWED_NODES.include?(@times_node.type) - result = chain_call(result, :times, @times_node) - elsif @times_node.type == :begin && @times_node.children[0]&.type == :irange - min_node, max_node = @times_node.children[0].children - - result = transform_irange_node(result, min_node, max_node) - elsif @times_node.type == :begin && @times_node.children[0]&.type == :erange - min_node, max_node = @times_node.children[0].children - max_node = chain_call(max_node, :-, s(:int, 1)) - - result = transform_erange_node(result, min_node, max_node) - else - raise ArgumentError, "Unrecognized times constraint in interaction: #{@times_node&.loc&.expression || "?"}" - end - - result = chain_call(result, :returns, @return_value_node) if @return_value_node - - result - end - - def chain_call(receiver_node, method_name, *arg_nodes) - s(:send, receiver_node, method_name, *arg_nodes) - end - - def transform_irange_node(receiver_node, min_node, max_node) - result = receiver_node - - if any_matcher_node?(min_node) && any_matcher_node?(max_node) - result = chain_call(result, :at_least, s(:int, 0)) - elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node) - result = chain_call(result, :at_least, min_node) - elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node) - result = chain_call(result, :at_least, s(:int, 0)) - result = chain_call(result, :at_most, max_node) - elsif !any_matcher_node?(min_node) && !any_matcher_node?(max_node) - result = chain_call(result, :at_least, min_node) - result = chain_call(result, :at_most, max_node) - end - - result - end - - def transform_erange_node(receiver_node, min_node, max_node) - result = receiver_node - - if any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0]) - result = chain_call(result, :at_least, s(:int, 0)) - elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0]) - result = chain_call(result, :at_least, min_node) - elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node.children[0]) - result = chain_call(result, :at_least, s(:int, 0)) - result = chain_call(result, :at_most, max_node) - elsif !any_matcher_node?(min_node) && !any_matcher_node?(max_node.children[0]) - result = chain_call(result, :at_least, min_node) - result = chain_call(result, :at_most, max_node) - end - - result - end - - def return_value_node?(node) - node.type == :send && node.children[1] == :>> && interaction_node?(node.children[0]) - end - - def any_matcher_node?(node) - node.type == :send && node.children[0].nil? && node.children[1] == :_ - end - - def parse_node(node) - # Unwrap `interaction >> return_value` into the underlying interaction, - # assigned to @return_value_node for transform_node to chain .returns(). - if return_value_node?(node) - @return_value_node = node.children[2] - node = node.children[0] - else - @return_value_node = nil - end - - parse_lhs(node.children[0]) - parse_rhs(node.children[2]) - end - - def parse_lhs(node) - @times_node = node - - case @times_node.type - when *ALLOWED_NODES - # OK - when :begin - if node.children.count > 1 - raise_lhs_error(node, msg_prefix: "Left-hand side of ", msg_suffix: " or a range in parentheses") - end - case node.children[0].type - when :irange, :erange - unless ALLOWED_NODES.include?(node.children[0].children[0].type) - raise_lhs_error(node.children[0].children[0], msg_prefix: "Minimum range of ") - end - unless ALLOWED_NODES.include?(node.children[0].children[1].type) - raise_lhs_error(node.children[0].children[1], msg_prefix: "Maximum range of ") - end - else - raise_lhs_error(node, msg_prefix: "Left-hand side of ", msg_suffix: " or a range in parentheses") - end - else - raise_lhs_error(node, msg_prefix: "Left-hand side of ", msg_suffix: " or a range in parentheses") - end - end - - def parse_rhs(node) - if node.type != :send - raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must be a :send node." - end - - @receiver_node, @message, *@arg_nodes = node.children - - if @receiver_node.nil? - raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must have a receiver." - end - end - - def range(node) - node&.loc&.expression || "?" - end - - def raise_lhs_error(node, msg_prefix: "", msg_suffix: "") - raise InteractionError, "#{msg_prefix}Interaction @ #{range(node)} must be one of "\ - "#{ALLOWED_NODES}#{msg_suffix}." - end - end - end -end diff --git a/lib/rspock/ast/node.rb b/lib/rspock/ast/node.rb new file mode 100644 index 0000000..68bc060 --- /dev/null +++ b/lib/rspock/ast/node.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true +require 'ast_transform/transformation_helper' + +module RSpock + module AST + class Node < ::Parser::AST::Node + REGISTRY = {} + + def self.register(type) + REGISTRY[type] = self + end + + def self.build(type, *children) + klass = REGISTRY[type] || self + klass.new(type, children) + end + end + + class TestNode < Node + register :rspock_test + + def def_node = children[0] + def body_node = children[1] + def where_node = children[2] + end + + class BodyNode < Node + register :rspock_body + end + + class DefNode < Node + register :rspock_def + + def method_call = children[0] + def args = children[1] + end + + class GivenNode < Node + register :rspock_given + end + + class WhenNode < Node + register :rspock_when + end + + class ThenNode < Node + register :rspock_then + end + + class ExpectNode < Node + register :rspock_expect + end + + class CleanupNode < Node + register :rspock_cleanup + end + + class WhereNode < Node + register :rspock_where + + def header + header_node = children.find { |n| n.type == :rspock_where_header } + header_node.children.map { |sym_node| sym_node.children[0] } + end + + def data_rows + children + .select { |n| n.type == :array } + .map(&:children) + end + end + + class InteractionNode < Node + register :rspock_interaction + + def cardinality = children[0] + def receiver = children[1] + def message_sym = children[2] + def message = message_sym.children[0] + def args = children[3] + def return_value = children[4] + def block_pass = children[5] + end + + module NodeBuilder + include ASTTransform::TransformationHelper + + def s(type, *children) + if type.to_s.start_with?('rspock_') + Node.build(type, *children) + else + super + end + end + end + end +end diff --git a/lib/rspock/ast/parser/block.rb b/lib/rspock/ast/parser/block.rb new file mode 100644 index 0000000..738a81e --- /dev/null +++ b/lib/rspock/ast/parser/block.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true +require 'rspock/ast/node' + +module RSpock + module AST + module Parser + class BlockError < StandardError; end + + class Block + include RSpock::AST::NodeBuilder + + # Constructs a new Block. + # + # @param type [Symbol] The Block type. + # @param node [Parser::AST::Node] The node associated to this Block. + def initialize(type, node) + @type = type + @node = node + @children = [] + end + + attr_reader :type, :node + + # Adds the given +child_node+ to this Block. + # + # @param child_node [Parser::AST::Node] The node to be added. + def <<(child_node) + @children << child_node + end + + # Retrieves the Parser::Source::Range for this Block. + # + # @return [Parser::Source::Range] The range. + def range + node&.loc&.expression || "?" + end + + # Whether this block can be the first block in a test method. + def can_start? + false + end + + # Whether this block can be the last block in a test method. + def can_end? + false + end + + # Retrieves the valid successors for this Block. + # + # @return [Array] This Block's successors. + def successors + @successors ||= [].freeze + end + + # Retrieves the duped array of children AST nodes for this Block. + # + # @return [Array] The children nodes. + def children + @children.dup + end + + # Converts this Block into an RSpock node. + # + # @return [Parser::AST::Node] A node with type :rspock_. + def to_rspock_node + rspock_type = :"rspock_#{type.downcase}" + s(rspock_type, *@children) + end + + # Checks whether or not the given +block+ is a valid successor for this Block. + # + # @param block [Block] The candidate successor. + # + # @return [Boolean] True if the given block is a valid successor, false otherwise. + def valid_successor?(block) + successors.include?(block.type) + end + + # Retrieves the error message for succession errors. + # + # @return [String] The error message. + def succession_error_msg + "Block #{type} @ #{range} must be followed by one of these Blocks: #{successors}" + end + end + end + end +end diff --git a/lib/rspock/ast/parser/cleanup_block.rb b/lib/rspock/ast/parser/cleanup_block.rb new file mode 100644 index 0000000..3494e9d --- /dev/null +++ b/lib/rspock/ast/parser/cleanup_block.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'rspock/ast/parser/block' + +module RSpock + module AST + module Parser + class CleanupBlock < Block + def initialize(node) + super(:Cleanup, node) + end + + def can_end? + true + end + + def successors + @successors ||= [:Where].freeze + end + end + end + end +end diff --git a/lib/rspock/ast/parser/expect_block.rb b/lib/rspock/ast/parser/expect_block.rb new file mode 100644 index 0000000..782813a --- /dev/null +++ b/lib/rspock/ast/parser/expect_block.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require 'rspock/ast/parser/block' + +module RSpock + module AST + module Parser + class ExpectBlock < Block + def initialize(node) + super(:Expect, node) + end + + def can_start? + true + end + + def can_end? + true + end + + def successors + @successors ||= [:Cleanup, :Where].freeze + end + end + end + end +end diff --git a/lib/rspock/ast/parser/given_block.rb b/lib/rspock/ast/parser/given_block.rb new file mode 100644 index 0000000..19a2cab --- /dev/null +++ b/lib/rspock/ast/parser/given_block.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'rspock/ast/parser/block' + +module RSpock + module AST + module Parser + class GivenBlock < Block + def initialize(node) + super(:Given, node) + end + + def can_start? + true + end + + def successors + @successors ||= [:When, :Expect].freeze + end + end + end + end +end diff --git a/lib/rspock/ast/parser/interaction_parser.rb b/lib/rspock/ast/parser/interaction_parser.rb new file mode 100644 index 0000000..ea20639 --- /dev/null +++ b/lib/rspock/ast/parser/interaction_parser.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true +require 'rspock/ast/node' + +module RSpock + module AST + module Parser + # Parses raw Ruby AST interaction nodes into structured :rspock_interaction nodes. + # + # Input: 1 * receiver.message("arg", &blk) >> "result" + # Output: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass) + # + # :rspock_interaction children: + # [0] cardinality - e.g. s(:int, 1), s(:begin, s(:irange, ...)), s(:send, nil, :_) + # [1] receiver - e.g. s(:send, nil, :subscriber) + # [2] message - e.g. s(:sym, :receive) + # [3] args - nil if no args, s(:array, *arg_nodes) otherwise + # [4] return_value - nil if no >>, otherwise the value node + # [5] block_pass - nil if no &, otherwise s(:block_pass, ...) + class InteractionParser + include RSpock::AST::NodeBuilder + + class InteractionError < RuntimeError; end + + ALLOWED_CARDINALITY_NODES = [:send, :lvar, :int].freeze + + def interaction_node?(node) + return false if node.nil? + return true if node.type == :send && node.children[1] == :* + return true if return_value_node?(node) + + false + end + + def parse(node) + return node unless interaction_node?(node) + + if return_value_node?(node) + return_value = node.children[2] + node = node.children[0] + end + + cardinality = node.children[0] + validate_cardinality(cardinality) + + rhs = node.children[2] + receiver, message, args, block_pass = parse_rhs(rhs) + + s(:rspock_interaction, + cardinality, + receiver, + s(:sym, message), + args, + return_value, + block_pass + ) + end + + private + + def return_value_node?(node) + node.type == :send && node.children[1] == :>> && interaction_node?(node.children[0]) + end + + def validate_cardinality(node) + case node.type + when *ALLOWED_CARDINALITY_NODES + # OK + when :begin + if node.children.count > 1 + raise_cardinality_error(node, + msg_prefix: "Left-hand side of ", + msg_suffix: " or a range in parentheses") + end + case node.children[0].type + when :irange, :erange + unless ALLOWED_CARDINALITY_NODES.include?(node.children[0].children[0].type) + raise_cardinality_error(node.children[0].children[0], msg_prefix: "Minimum range of ") + end + unless ALLOWED_CARDINALITY_NODES.include?(node.children[0].children[1].type) + raise_cardinality_error(node.children[0].children[1], msg_prefix: "Maximum range of ") + end + else + raise_cardinality_error(node, + msg_prefix: "Left-hand side of ", + msg_suffix: " or a range in parentheses") + end + else + raise_cardinality_error(node, + msg_prefix: "Left-hand side of ", + msg_suffix: " or a range in parentheses") + end + end + + def parse_rhs(node) + if node.type == :block + raise InteractionError, "Inline blocks (do...end / { }) are not supported in interactions @ #{range(node)}. " \ + "Use &var for block forwarding verification, or << for method body override (future)." + end + + if node.type != :send + raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must be a :send node." + end + + receiver, message, *arg_nodes = node.children + + if receiver.nil? + raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must have a receiver." + end + + block_pass = arg_nodes.find { |n| n.type == :block_pass } + if block_pass + arg_nodes = arg_nodes.reject { |n| n.equal?(block_pass) } + end + + args = arg_nodes.empty? ? nil : s(:array, *arg_nodes) + + [receiver, message, args, block_pass] + end + + def range(node) + node&.loc&.expression || "?" + end + + def raise_cardinality_error(node, msg_prefix: "", msg_suffix: "") + raise InteractionError, "#{msg_prefix}Interaction @ #{range(node)} must be one of " \ + "#{ALLOWED_CARDINALITY_NODES}#{msg_suffix}." + end + end + end + end +end diff --git a/lib/rspock/ast/parser/test_method_parser.rb b/lib/rspock/ast/parser/test_method_parser.rb new file mode 100644 index 0000000..67047a0 --- /dev/null +++ b/lib/rspock/ast/parser/test_method_parser.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true +require 'rspock/ast/node' + +module RSpock + module AST + module Parser + # Parses a Ruby test method AST node into a self-contained RSpock AST. + # + # Input: s(:block, s(:send, nil, :test, ...), s(:args), s(:begin, ...)) + # Output: s(:rspock_test, + # s(:rspock_def, method_call_node, args_node), + # s(:rspock_body, s(:rspock_given, ...), s(:rspock_when, ...), ...), + # s(:rspock_where, ...)) # optional + class TestMethodParser + include RSpock::AST::NodeBuilder + + def initialize(block_registry, strict: true) + @block_registry = block_registry + @strict = strict + end + + # Parses a Ruby test method AST into an RSpock AST (TestNode). + # Returns nil when non-strict and no RSpock blocks are found. + def parse(node) + blocks = parse_blocks(node) + + if blocks.empty? + return nil unless @strict + raise BlockError, "Test method @ #{node.loc&.expression || '?'} must start with one of: Given, When, Expect" + end + + validate_blocks(blocks, node) + build_rspock_ast(node, blocks) + end + + private + + def parse_blocks(node) + blocks = [] + test_method_nodes(node).each do |n| + new_block = parse_block(n) + if new_block + validate_succession(blocks, new_block) + blocks << new_block + elsif blocks.empty? + raise BlockError, "Test method must start with one of: Given, When, Expect" if @strict + # non-strict: ignore pre-block statements in plain minitest tests + else + # regular statement — associate with the current block as a child + blocks.last << n + end + end + blocks + end + + def parse_block(node) + return unless @block_registry.key?(node.children[1]) + + @block_registry[node.children[1]].new(node) + end + + def validate_succession(blocks, new_block) + return if blocks.empty? + + current = blocks.last + unless current.valid_successor?(new_block) + raise BlockError, current.succession_error_msg + end + end + + def validate_blocks(blocks, node) + unless blocks.first.can_start? + raise BlockError, "Test method @ #{node.loc&.expression || '?'} must start with one of: Given, When, Expect" + end + + unless blocks.last.can_end? + raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} must be followed by one of these Blocks: #{blocks.last.successors}" + end + end + + def test_method_nodes(node) + return [] if node.children[2].nil? + + node.children[2]&.type == :begin ? node.children[2].children : [node.children[2]] + end + + def build_rspock_ast(node, blocks) + def_node = s(:rspock_def, node.children[0], node.children[1]) + where_block = blocks.find { |b| b.type == :Where } + body_blocks = blocks.reject { |b| b.type == :Where } + + body_node = s(:rspock_body, *body_blocks.map(&:to_rspock_node)) + + if where_block + s(:rspock_test, def_node, body_node, where_block.to_rspock_node) + else + s(:rspock_test, def_node, body_node) + end + end + end + end + end +end diff --git a/lib/rspock/ast/parser/then_block.rb b/lib/rspock/ast/parser/then_block.rb new file mode 100644 index 0000000..82fa54b --- /dev/null +++ b/lib/rspock/ast/parser/then_block.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require 'rspock/ast/parser/block' +require 'rspock/ast/parser/interaction_parser' + +module RSpock + module AST + module Parser + class ThenBlock < Block + def initialize(node) + super(:Then, node) + end + + def can_end? + true + end + + def successors + @successors ||= [:Cleanup, :Where].freeze + end + + def to_rspock_node + parser = InteractionParser.new + spock_children = @children.map { |child| parser.parse(child) } + s(:rspock_then, *spock_children) + end + end + end + end +end diff --git a/lib/rspock/ast/parser/when_block.rb b/lib/rspock/ast/parser/when_block.rb new file mode 100644 index 0000000..b816222 --- /dev/null +++ b/lib/rspock/ast/parser/when_block.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'rspock/ast/parser/block' + +module RSpock + module AST + module Parser + class WhenBlock < Block + def initialize(node) + super(:When, node) + end + + def can_start? + true + end + + def successors + @successors ||= [:Then].freeze + end + end + end + end +end diff --git a/lib/rspock/ast/parser/where_block.rb b/lib/rspock/ast/parser/where_block.rb new file mode 100644 index 0000000..612b26b --- /dev/null +++ b/lib/rspock/ast/parser/where_block.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +require 'rspock/ast/parser/block' + +module RSpock + module AST + module Parser + class WhereBlock < Block + class MalformedError < StandardError; end + + def initialize(node) + super(:Where, node) + end + + def header + @header ||= parse_header + end + + def data + @data ||= parse_data + end + + def to_rspock_node + header_node = s(:rspock_where_header, *header.map { |col| s(:sym, col) }) + data_nodes = data.map { |row| s(:array, *row) } + s(:rspock_where, header_node, *data_nodes) + end + + def can_end? + true + end + + private + + def parse_header + header = [] + header_pipe_node?(children.first, header) || terminal_header_node?(children.first, header) + header + end + + def header_pipe_node?(node, header) + return false if node.nil? + + node.type == :send && + node.children.count == 3 && + (header_pipe_node?(node.children[0], header) || terminal_header_node?(node.children[0], header)) && + node.children[1] == :| && + terminal_header_node?(node.children[2], header) + end + + def terminal_header_node?(node, header) + return false if node.nil? + + result = node.type == :send && + node.children.count == 2 && + node.children.first.nil? && + node.children.last.is_a?(Symbol) + + raise MalformedError, "Where Block is malformed at location: #{node.loc&.expression || "?"}" unless result + + header << node.children.last if result + + result + end + + def parse_data + _header_node, *row_nodes = children + row_nodes.map do |node| + data = [] + data_pipe_node?(node, data) || terminal_data_node?(node, data) + data + end + end + + def data_pipe_node?(node, data) + return false if node.nil? + return false unless node.type == :send && node.children.count == 3 && node.children[1] == :| + + unless data_pipe_node?(node.children[0], data) + terminal_data_node?(node.children[0], data) + end + + unless data_pipe_node?(node.children[2], data) + terminal_data_node?(node.children[2], data) + end + end + + def terminal_data_node?(node, data) + data << node + true + end + end + end + end +end diff --git a/lib/rspock/ast/start_block.rb b/lib/rspock/ast/start_block.rb deleted file mode 100644 index 8e1bd09..0000000 --- a/lib/rspock/ast/start_block.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' - -module RSpock - module AST - class StartBlock < Block - def initialize(node) - super(:Start, node) - @node_container = false - end - - def successors - if @children.empty? - SUCCESSORS_WITHOUT_CHILDREN - else - SUCCESSORS_WITH_CHILDREN - end - end - - def succession_error_msg - "Test method @ #{range} must start with one of these Blocks: #{successors}" - end - - SUCCESSORS_WITHOUT_CHILDREN = [:Given, :When, :Expect].freeze - SUCCESSORS_WITH_CHILDREN = [:End].freeze - end - end -end diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index 72d3e1f..660780c 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -1,165 +1,164 @@ # frozen_string_literal: true require 'ast_transform/abstract_transformation' +require 'rspock/ast/node' +require 'rspock/ast/comparison_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' require 'rspock/ast/method_call_to_lvar_transformation' require 'rspock/ast/test_method_def_transformation' +require 'rspock/ast/parser/test_method_parser' module RSpock module AST class TestMethodTransformation < ASTTransform::AbstractTransformation - def initialize(source_map, start_block_class, end_block_class, strict: true) - @source_map = source_map - @start_block_class = start_block_class - @end_block_class = end_block_class - @strict = strict - @blocks = [] + def initialize(block_registry, strict: true) + @parser = Parser::TestMethodParser.new(block_registry, strict: strict) + @comparison_transformation = ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_) end def run(node) - parse(node) - build_ast(node) + rspock_ast = @parser.parse(node) + return node if rspock_ast.nil? + transform(rspock_ast) end private - def parse(node) - start_block = @start_block_class.new(node) - start_block.node_container = !@strict + def transform(rspock_ast) + hoisted_setups = [] + + method_call = rspock_ast.def_node.method_call + method_args = rspock_ast.def_node.args + where = rspock_ast.where_node + + transformed_blocks = rspock_ast.body_node.children.map do |block_node| + case block_node.type + when :rspock_then + transform_then_block(block_node, hoisted_setups) + when :rspock_expect + transform_expect_block(block_node) + else + block_node + end + end - add_block(start_block) - test_method_nodes(node).each { |n| parse_node(n) } - add_block(@end_block_class.new) - nil + transformed_body = rspock_ast.body_node.updated(nil, transformed_blocks) + build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups) end - def build_ast(node) - if where_block - s(:block, - build_where_block_iterator(where_block.data), - build_where_block_args(where_block.header), - build_test_method_def(node) - ) - else - build_test_method_def(node) + def transform_then_block(then_node, hoisted_setups) + interaction_setups = [] + then_children = [] + + then_node.children.each_with_index do |child, idx| + if child.type == :rspock_interaction + setup = InteractionToMochaMockTransformation.new(idx).run(child) + assertion = InteractionToBlockIdentityAssertionTransformation.new(idx).run(child) + + interaction_setups << setup + then_children << assertion unless assertion.equal?(child) + else + then_children << @comparison_transformation.run(child) + end end - end - def build_where_block_iterator(rows) - s(:send, - s(:send, - s(:array, *build_where_block_data_rows(rows)), - :each, - ), - :with_index - ) - end + unless interaction_setups.empty? + interaction_setups.each do |node| + if node.type == :begin + hoisted_setups.concat(node.children) + else + hoisted_setups << node + end + end + end - def build_where_block_data_rows(rows) - rows.map(&method(:build_where_block_data_row)) + then_node.updated(nil, then_children) end - def build_where_block_data_row(row) - children = row.dup - children << s(:int, row.first&.loc&.expression&.line) - - s(:array, *children) + def transform_expect_block(expect_node) + new_children = expect_node.children.map { |child| @comparison_transformation.run(child) } + expect_node.updated(nil, new_children) end - def build_where_block_args(header) - injected_args = header.map { |column| s(:arg, column) } - injected_args << s(:arg, :_line_number_) + # --- Build final Ruby AST --- - s(:args, - s(:mlhs, *injected_args), - s(:arg, :_test_index_), - ) - end + def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups) + if where + test_def = s(:block, + TestMethodDefTransformation.new.run(method_call), + method_args, + build_test_body(body_node, hoisted_setups) + ) + test_def = HeaderNodesTransformation.new(where.header).run(test_def) - def build_test_method_def(node) - if where_block - ast = s(:block, - TestMethodDefTransformation.new.run(node.children[0]), - node.children[1], - build_test_body + s(:block, + build_where_iterator(where.data_rows), + build_where_args(where.header), + test_def ) - HeaderNodesTransformation.new(where_block.header).run(ast) else s(:block, - node.children[0], - node.children[1], - build_test_body + method_call, + method_args, + build_test_body(body_node, hoisted_setups) ) end end - def first_scope - @blocks.first - end - - def current_scope - @blocks.last - end - - def add_block(block) - if current_scope && !current_scope.valid_successor?(block) - raise RSpock::AST::BlockError, current_scope.succession_error_msg + def build_test_body(body_node, hoisted_setups) + body_children = [] + + body_node.children.each do |block_node| + case block_node.type + when :rspock_given + body_children.concat(block_node.children) + when :rspock_when + body_children.concat(hoisted_setups) + body_children.concat(block_node.children) + when :rspock_then, :rspock_expect + body_children.concat(block_node.children) + when :rspock_cleanup + # handled below as ensure + end end - @blocks << block - end + ast = s(:begin, *body_children) - def test_method_nodes(node) - return [] if node.children[2].nil? - - node.children[2]&.type == :begin ? node.children[2].children : [node.children[2]] - end - - def parse_node(node) - if @source_map.key?(node.children[1]) - add_block(build_block(node)) - else - current_scope << node + cleanup = body_node.children.find { |n| n.type == :rspock_cleanup } + if cleanup && !cleanup.children.empty? + ensure_node = s(:begin, *cleanup.children) + ast = s(:kwbegin, s(:ensure, ast, ensure_node)) end - end - - def build_block(node) - @source_map[node.children[1]].new(node) - end - def when_block - @when_block ||= @blocks.detect { |block| block.type == :When } + MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast) end - def then_block - @then_block ||= @blocks.detect { |block| block.type == :Then } - end + # --- Where block helpers --- - def where_block - @where_block ||= @blocks.detect { |block| block.type == :Where } + def build_where_iterator(data_rows) + s(:send, + s(:send, + s(:array, *data_rows.map { |row| build_where_data_row(row) }), + :each, + ), + :with_index + ) end - def cleanup_block - @cleanup_block ||= @blocks.detect { |block| block.type == :Cleanup } + def build_where_data_row(row) + children = row.dup + children << s(:int, row.first&.loc&.expression&.line) + s(:array, *children) end - def build_test_body - then_block&.interactions&.reverse&.each { |interaction| when_block.unshift(interaction) } - - test_body_children = @blocks.select { |block| [:Start, :Given, :When, :Then, :Expect].include?(block.type) } - .map { |block| block.children } - .flatten - - ast = s(:begin, *test_body_children) - - if cleanup_block && !cleanup_block.children.empty? - ensure_node = s(:begin, *cleanup_block.children) - - ast = s(:kwbegin, - s(:ensure, ast, ensure_node) - ) - end - - MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast) + def build_where_args(header) + injected_args = header.map { |column| s(:arg, column) } + injected_args << s(:arg, :_line_number_) + s(:args, + s(:mlhs, *injected_args), + s(:arg, :_test_index_), + ) end end end diff --git a/lib/rspock/ast/then_block.rb b/lib/rspock/ast/then_block.rb deleted file mode 100644 index b6f97db..0000000 --- a/lib/rspock/ast/then_block.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' -require 'rspock/ast/comparison_to_assertion_transformation' -require 'rspock/ast/interaction_transformation' - -module RSpock - module AST - class ThenBlock < Block - def initialize(node) - super(:Then, node) - end - - def successors - @successors ||= [:Cleanup, :Where, :End].freeze - end - - def children - super.reject { |child| interaction_transformation.interaction_node?(child) } - .map { |child| ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_).run(child) } - end - - def interactions - @children.select { |child| interaction_transformation.interaction_node?(child) } - .map { |child| interaction_transformation.run(child) } - end - - private - - def interaction_transformation - @interaction_transformation ||= RSpock::AST::InteractionTransformation.new - end - end - end -end diff --git a/lib/rspock/ast/transformation.rb b/lib/rspock/ast/transformation.rb index 0092bcc..7ec4c4b 100644 --- a/lib/rspock/ast/transformation.rb +++ b/lib/rspock/ast/transformation.rb @@ -1,37 +1,31 @@ # frozen_string_literal: true require 'ast_transform/abstract_transformation' -require 'rspock/ast/start_block' -require 'rspock/ast/given_block' -require 'rspock/ast/when_block' -require 'rspock/ast/then_block' -require 'rspock/ast/expect_block' -require 'rspock/ast/cleanup_block' -require 'rspock/ast/where_block' -require 'rspock/ast/end_block' +require 'rspock/ast/parser/given_block' +require 'rspock/ast/parser/when_block' +require 'rspock/ast/parser/then_block' +require 'rspock/ast/parser/expect_block' +require 'rspock/ast/parser/cleanup_block' +require 'rspock/ast/parser/where_block' require 'rspock/ast/test_method_transformation' module RSpock module AST class Transformation < ASTTransform::AbstractTransformation - DefaultSourceMap = { - Given: RSpock::AST::GivenBlock, - When: RSpock::AST::WhenBlock, - Then: RSpock::AST::ThenBlock, - Expect: RSpock::AST::ExpectBlock, - Cleanup: RSpock::AST::CleanupBlock, - Where: RSpock::AST::WhereBlock, + DEFAULT_BLOCK_REGISTRY = { + Given: Parser::GivenBlock, + When: Parser::WhenBlock, + Then: Parser::ThenBlock, + Expect: Parser::ExpectBlock, + Cleanup: Parser::CleanupBlock, + Where: Parser::WhereBlock, }.freeze def initialize( - start_block_class: StartBlock, - end_block_class: EndBlock, - source_map: DefaultSourceMap, + block_registry: DEFAULT_BLOCK_REGISTRY, strict: true ) super() - @start_block_class = start_block_class - @source_map = source_map - @end_block_class = end_block_class + @block_registry = block_registry @strict = strict end @@ -93,9 +87,7 @@ def on_block(node) end TestMethodTransformation.new( - @source_map, - @start_block_class, - @end_block_class, + @block_registry, strict: @strict ).run(node) end diff --git a/lib/rspock/ast/when_block.rb b/lib/rspock/ast/when_block.rb deleted file mode 100644 index c785f4f..0000000 --- a/lib/rspock/ast/when_block.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' - -module RSpock - module AST - class WhenBlock < Block - def initialize(node) - super(:When, node) - end - - def successors - @successors ||= [:Then].freeze - end - end - end -end diff --git a/lib/rspock/ast/where_block.rb b/lib/rspock/ast/where_block.rb deleted file mode 100644 index 142bc84..0000000 --- a/lib/rspock/ast/where_block.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true -require 'rspock/ast/block' - -module RSpock - module AST - class WhereBlock < Block - class MalformedError < StandardError; end - - def initialize(node) - super(:Where, node) - end - - def header - @header ||= parse_header - end - - def data - @data ||= parse_data - end - - def successors - @successors ||= [:End].freeze - end - - private - - def parse_header - header = [] - header_pipe_node?(children.first, header) || terminal_header_node?(children.first, header) - header - end - - def header_pipe_node?(node, header) - return false if node.nil? - - node.type == :send && - node.children.count == 3 && - (header_pipe_node?(node.children[0], header) || terminal_header_node?(node.children[0], header)) && - node.children[1] == :| && - terminal_header_node?(node.children[2], header) - end - - def terminal_header_node?(node, header) - return false if node.nil? - - result = node.type == :send && - node.children.count == 2 && - node.children.first.nil? && - node.children.last.is_a?(Symbol) - - raise MalformedError, "Where Block is malformed at location: #{node.loc&.expression || "?"}" unless result - - header << node.children.last if result - - result - end - - def parse_data - _header_node, *row_nodes = children - row_nodes.map do |node| - data = [] - data_pipe_node?(node, data) || terminal_data_node?(node, data) - data - end - end - - def data_pipe_node?(node, data) - return false if node.nil? - return false unless node.type == :send && node.children.count == 3 && node.children[1] == :| - - unless data_pipe_node?(node.children[0], data) - terminal_data_node?(node.children[0], data) - end - - unless data_pipe_node?(node.children[2], data) - terminal_data_node?(node.children[2], data) - end - end - - def terminal_data_node?(node, data) - data << node - true - end - end - end -end diff --git a/lib/rspock/helpers/block_capture.rb b/lib/rspock/helpers/block_capture.rb new file mode 100644 index 0000000..c60d50c --- /dev/null +++ b/lib/rspock/helpers/block_capture.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module RSpock + module Helpers + module BlockCapture + # Installs a block-capture wrapper on +obj+ for +method_name+. + # Must be called AFTER Mocha's expects/stubs so the wrapper sits + # in front of whatever Mocha installed. + # + # Returns a lambda that, when called, returns the captured block + # (or nil if no block was passed). + def self.capture(obj, method_name) + state = { captured: nil } + + if obj.respond_to?(method_name, true) + # Real objects or objects where Mocha defined the method on + # the singleton class. Prepend a module so we intercept the + # call before Mocha's stub (prepend wins over define_singleton_method). + s = state + capture_mod = Module.new do + define_method(method_name) do |*args, **kwargs, &blk| + s[:captured] = blk + super(*args, **kwargs, &blk) + end + end + obj.singleton_class.prepend(capture_mod) + else + # Mock objects where the method goes through method_missing. + original_mm = obj.method(:method_missing) + s = state + obj.define_singleton_method(:method_missing) do |name, *args, **kwargs, &blk| + s[:captured] = blk if name == method_name + original_mm.call(name, *args, **kwargs, &blk) + end + end + + -> { state[:captured] } + end + end + end +end diff --git a/test/rspock/ast/block_test.rb b/test/rspock/ast/block_test.rb deleted file mode 100644 index 7abe920..0000000 --- a/test/rspock/ast/block_test.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'transformation_helper' -require 'rspock/ast/block' - -module RSpock - module AST - class BlockTest < Minitest::Test - extend RSpock::Declarative - include RSpock::Helpers::TransformationHelper - - def setup - @block = RSpock::AST::Block.new(:Start, nil) - @node = s(:send, nil, :a) - end - - test "#<< adds the given node if node_container is true" do - @block << @node - - assert_equal [@node], @block.children - end - - test "#<< raises if node_container is false" do - @block.node_container = false - - assert_raises RSpock::AST::BlockError do - @block << @node - end - end - - test "#unshift adds the given node to the beginning of the Block node_container is true" do - @block << 1 - - @block.unshift(@node) - - assert_equal [@node, 1], @block.children - end - - test "#unshift raises if node_container is false" do - @block.node_container = false - - assert_raises RSpock::AST::BlockError do - @block.unshift(@node) - end - end - - test "#range returns '?' if node does not contain range information" do - assert_equal '?', @block.range - end - - test "#successors returns the correct successors" do - assert_equal [:End], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#children is duped" do - original = @block.children - - modified = @block.children - modified << @node - - refute_same original, modified - refute_equal original, modified - end - - test "#valid_successor? returns true if block passed is a valid successor" do - end_block = RSpock::AST::Block.new(:End, nil) - - assert_equal true, @block.valid_successor?(end_block) - end - - test "#valid_successor? returns false if block passed is not a valid successor" do - end_block = RSpock::AST::Block.new(:DummyType, nil) - - assert_equal false, @block.valid_successor?(end_block) - end - - test "#succession_error_msg returns the correct error message" do - expected = "Block Start @ ? must be followed by one of these Blocks: [:End]" - - assert_equal expected, @block.succession_error_msg - end - end - end -end diff --git a/test/rspock/ast/cleanup_block_test.rb b/test/rspock/ast/cleanup_block_test.rb deleted file mode 100644 index 4e4541f..0000000 --- a/test/rspock/ast/cleanup_block_test.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'rspock/ast/cleanup_block' - -module RSpock - module AST - class CleanupBlockTest < Minitest::Test - extend RSpock::Declarative - - def setup - @block = RSpock::AST::CleanupBlock.new(nil) - end - - test "#successors returns the correct successors" do - assert_equal [:Where, :End], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :Cleanup" do - assert_equal :Cleanup, @block.type - end - end - end -end diff --git a/test/rspock/ast/end_block_test.rb b/test/rspock/ast/end_block_test.rb deleted file mode 100644 index 3c6be0e..0000000 --- a/test/rspock/ast/end_block_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'rspock/ast/end_block' - -module RSpock - module AST - class EndBlockTest < Minitest::Test - extend RSpock::Declarative - - def setup - @block = RSpock::AST::EndBlock.new - end - - test "#node_container? returns false by default" do - assert_equal false, @block.node_container? - end - - test "#successors returns empty array" do - assert_equal [], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :End" do - assert_equal :End, @block.type - end - end - end -end diff --git a/test/rspock/ast/expect_block_test.rb b/test/rspock/ast/expect_block_test.rb deleted file mode 100644 index 1eaabd8..0000000 --- a/test/rspock/ast/expect_block_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'rspock/ast/expect_block' - -module RSpock - module AST - class ExpectBlockTest < Minitest::Test - extend RSpock::Declarative - include ASTTransform::TransformationHelper - - def setup - @block = RSpock::AST::ExpectBlock.new(nil) - end - - test "#node_container? returns true by default" do - assert_equal true, @block.node_container? - end - - test "#successors returns the correct successors" do - assert_equal [:Cleanup, :Where, :End], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :Then" do - assert_equal :Expect, @block.type - end - - test "#children returns transformed children when comparing with == or !=" do - @block << s(:send, 1, :==, 2) - @block << s(:send, 1, :!=, 2) - - actual = @block.children - expected = [ - s(:send, nil, :assert_equal, 2, 1), - s(:send, nil, :refute_equal, 2, 1) - ] - - assert_equal expected, actual - end - - test "#children ignores test_index and _line_number_ comparisons when comparing with == or !=" do - test_index_ast = s(:send, 1, :==, s(:send, nil, :_test_index_)) - line_number_ast = s(:send, 1, :!=, s(:send, nil, :_line_number_)) - - @block << test_index_ast - @block << line_number_ast - - actual = @block.children - expected = [test_index_ast, line_number_ast] - - assert_equal expected, actual - end - end - end -end diff --git a/test/rspock/ast/given_block_test.rb b/test/rspock/ast/given_block_test.rb deleted file mode 100644 index 5f11f3b..0000000 --- a/test/rspock/ast/given_block_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'rspock/ast/given_block' - -module RSpock - module AST - class GivenBlockTest < Minitest::Test - extend RSpock::Declarative - - def setup - @block = RSpock::AST::GivenBlock.new(nil) - end - - test "#node_container? returns true by default" do - assert_equal true, @block.node_container? - end - - test "#successors returns the correct successors" do - assert_equal [:When, :Expect], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :Given" do - assert_equal :Given, @block.type - end - end - end -end diff --git a/test/rspock/ast/interaction_to_block_identity_assertion_transformation_test.rb b/test/rspock/ast/interaction_to_block_identity_assertion_transformation_test.rb new file mode 100644 index 0000000..4e3918e --- /dev/null +++ b/test/rspock/ast/interaction_to_block_identity_assertion_transformation_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/interaction_parser' +require 'rspock/ast/interaction_to_block_identity_assertion_transformation' + +module RSpock + module AST + class InteractionToBlockIdentityAssertionTransformationTest < Minitest::Test + extend RSpock::Declarative + include ASTTransform::TransformationHelper + + test "#run returns passthrough for interaction without &block" do + source = '1 * receiver.message("arg")' + ir_node = parse_to_ir(source) + + result = InteractionToBlockIdentityAssertionTransformation.new(0).run(ir_node) + assert result.equal?(ir_node), "expected passthrough (same object identity)" + end + + test "#run returns assert_same node for interaction with &block" do + source = '1 * receiver.message("arg", &my_proc)' + ir_node = parse_to_ir(source) + + result = InteractionToBlockIdentityAssertionTransformation.new(0).run(ir_node) + refute result.equal?(ir_node) + + expected = s(:send, nil, :assert_same, + s(:send, nil, :my_proc), + s(:send, s(:lvar, :__rspock_blk_0), :call) + ) + assert_equal expected, result + end + + test "#run uses index for unique variable names" do + source = '1 * receiver.message(&callback)' + ir_node = parse_to_ir(source) + + result = InteractionToBlockIdentityAssertionTransformation.new(2).run(ir_node) + + expected = s(:send, nil, :assert_same, + s(:send, nil, :callback), + s(:send, s(:lvar, :__rspock_blk_2), :call) + ) + assert_equal expected, result + end + + test "#run returns passthrough for >> interaction without &block" do + source = '1 * receiver.message >> "result"' + ir_node = parse_to_ir(source) + + result = InteractionToBlockIdentityAssertionTransformation.new(0).run(ir_node) + assert result.equal?(ir_node), "expected passthrough (same object identity)" + end + + test "#run returns assertion for >> interaction with &block" do + source = '1 * receiver.message(&my_proc) >> "result"' + ir_node = parse_to_ir(source) + + result = InteractionToBlockIdentityAssertionTransformation.new(0).run(ir_node) + refute result.equal?(ir_node) + + expected = s(:send, nil, :assert_same, + s(:send, nil, :my_proc), + s(:send, s(:lvar, :__rspock_blk_0), :call) + ) + assert_equal expected, result + end + + test "#run returns node unchanged for non-interaction nodes" do + node = s(:send, 1, :==, 2) + result = InteractionToBlockIdentityAssertionTransformation.new(0).run(node) + assert_equal node, result + end + + private + + def parse_to_ir(source) + ast = ASTTransform::Transformer.new.build_ast(source) + Parser::InteractionParser.new.parse(ast) + end + end + end +end diff --git a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb new file mode 100644 index 0000000..a7af222 --- /dev/null +++ b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true +require 'test_helper' +require 'string_helper' +require 'rspock/ast/parser/interaction_parser' +require 'rspock/ast/interaction_to_mocha_mock_transformation' + +module RSpock + module AST + class InteractionToMochaMockTransformationTest < Minitest::Test + extend RSpock::Declarative + include RSpock::Helpers::StringHelper + include ASTTransform::TransformationHelper + + def setup + @transformation = InteractionToMochaMockTransformation.new + @transformer = ASTTransform::Transformer.new + end + + test "message without params" do + assert_transforms( + '1 * receiver.message', + 'receiver.expects(:message).times(1)' + ) + end + + test "message with params" do + assert_transforms( + '1 * receiver.message(param1, *param2, p3: param3)', + 'receiver.expects(:message).with(param1, *param2, p3: param3).times(1)' + ) + end + + test "chained receiver" do + assert_transforms( + '1 * base_object.receiver.message', + 'base_object.receiver.expects(:message).times(1)' + ) + end + + test "any_matcher cardinality" do + assert_transforms( + '_ * receiver.message', + 'receiver.expects(:message).at_least(0)' + ) + end + + test "irange cardinality" do + assert_transforms( + '(1..2) * receiver.message', + 'receiver.expects(:message).at_least(1).at_most(2)' + ) + end + + test "irange with any_matcher min" do + assert_transforms( + '(_..2) * receiver.message', + 'receiver.expects(:message).at_least(0).at_most(2)' + ) + end + + test "irange with any_matcher max" do + assert_transforms( + '(1.._) * receiver.message', + 'receiver.expects(:message).at_least(1)' + ) + end + + test "erange cardinality" do + assert_transforms( + '(1...3) * receiver.message', + 'receiver.expects(:message).at_least(1).at_most(3 - 1)' + ) + end + + test "erange with any_matcher min" do + assert_transforms( + '(_...3) * receiver.message', + 'receiver.expects(:message).at_least(0).at_most(3 - 1)' + ) + end + + test "erange with any_matcher max" do + assert_transforms( + '(1..._) * receiver.message', + 'receiver.expects(:message).at_least(1)' + ) + end + + test "irange with method call as min" do + assert_transforms( + '(min_value..2) * receiver.message', + 'receiver.expects(:message).at_least(min_value).at_most(2)' + ) + end + + test "irange with method call as max" do + assert_transforms( + '(1..max_value) * receiver.message', + 'receiver.expects(:message).at_least(1).at_most(max_value)' + ) + end + + test "erange with method call as min" do + assert_transforms( + '(min_value...3) * receiver.message', + 'receiver.expects(:message).at_least(min_value).at_most(3 - 1)' + ) + end + + test "erange with method call as max" do + assert_transforms( + '(1...max_value) * receiver.message', + 'receiver.expects(:message).at_least(1).at_most(max_value - 1)' + ) + end + + test ">> stubs return value" do + assert_transforms( + '1 * receiver.message >> "result"', + 'receiver.expects(:message).times(1).returns("result")' + ) + end + + test ">> with params" do + assert_transforms( + '1 * receiver.message(param1, param2) >> "result"', + 'receiver.expects(:message).with(param1, param2).times(1).returns("result")' + ) + end + + test ">> with complex expression" do + assert_transforms( + '1 * receiver.message >> [1, 2, 3]', + 'receiver.expects(:message).times(1).returns([1, 2, 3])' + ) + end + + test ">> with range cardinality" do + assert_transforms( + '(1..3) * receiver.message >> "result"', + 'receiver.expects(:message).at_least(1).at_most(3).returns("result")' + ) + end + + test ">> with any matcher cardinality" do + assert_transforms( + '_ * receiver.message >> "result"', + 'receiver.expects(:message).at_least(0).returns("result")' + ) + end + + test "interaction without >> has no returns" do + result = transform('1 * receiver.message') + refute_match(/returns/, result) + end + + test "&block produces setup with expects and capture" do + ir_node = parse_to_ir('1 * receiver.message("arg", &my_proc)') + result = @transformation.run(ir_node) + + source = Unparser.unparse(result) + assert_match(/receiver\.expects\(:message\)\.with\("arg"\)\.times\(1\)/, source) + assert_match(/RSpock::Helpers::BlockCapture\.capture\(receiver, :message\)/, source) + end + + test "&block without other args" do + ir_node = parse_to_ir('1 * receiver.message(&my_proc)') + result = @transformation.run(ir_node) + + source = Unparser.unparse(result) + refute_match(/\.with\(/, source) + assert_match(/receiver\.expects\(:message\)\.times\(1\)/, source) + assert_match(/BlockCapture\.capture/, source) + end + + test "&block with >> produces returns and capture" do + ir_node = parse_to_ir('1 * receiver.message(&my_proc) >> "result"') + result = @transformation.run(ir_node) + + source = Unparser.unparse(result) + assert_match(/\.returns\("result"\)/, source) + assert_match(/BlockCapture\.capture/, source) + end + + test "unique index produces unique capture variable names" do + ir_node = parse_to_ir('1 * receiver.message(&my_proc)') + + result0 = InteractionToMochaMockTransformation.new(0).run(ir_node) + result1 = InteractionToMochaMockTransformation.new(1).run(ir_node) + + assert_match(/__rspock_blk_0/, Unparser.unparse(result0)) + assert_match(/__rspock_blk_1/, Unparser.unparse(result1)) + end + + test "non-interaction node is returned unchanged" do + node = s(:send, 1, :==, 2) + result = @transformation.run(node) + assert_equal node, result + end + + private + + def parse_to_ir(source) + ast = @transformer.build_ast(source) + Parser::InteractionParser.new.parse(ast) + end + + def transform(source) + ir_node = parse_to_ir(source) + result = @transformation.run(ir_node) + Unparser.unparse(result) + end + + def assert_transforms(source, expected) + assert_equal strip_end_line(expected + "\n"), transform(source) + end + end + end +end diff --git a/test/rspock/ast/interaction_transformation_test.rb b/test/rspock/ast/interaction_transformation_test.rb deleted file mode 100644 index aa6f704..0000000 --- a/test/rspock/ast/interaction_transformation_test.rb +++ /dev/null @@ -1,359 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'string_helper' -require 'rspock/ast/interaction_transformation' -require 'transformation_helper' - -module RSpock - module AST - class InteractionTransformationTest < Minitest::Test - extend RSpock::Declarative - include RSpock::Helpers::StringHelper - include RSpock::Helpers::TransformationHelper - - def setup - @transformation = RSpock::AST::InteractionTransformation.new - end - - test "message without params is transformed properly" do - source = <<~HEREDOC - 1 * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).times(1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "message with params is transformed properly" do - source = <<~HEREDOC - 1 * receiver.message(param1, *param2, p3: param3) - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).with(param1, *param2, p3: param3).times(1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "chained receiver is transformed properly" do - source = <<~HEREDOC - 1 * base_object.receiver.message - HEREDOC - - expected = <<~HEREDOC - base_object.receiver.expects(:message).times(1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "any_matcher is transformed to at_least(0)" do - source = <<~HEREDOC - _ * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(0) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "irange with ints is transformed properly" do - source = <<~HEREDOC - (1..2) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(1).at_most(2) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "irange with any_matcher as min is transformed properly" do - source = <<~HEREDOC - (_..2) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(0).at_most(2) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "irange with method call as min is transformed properly" do - source = <<~HEREDOC - (min_value..2) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(min_value).at_most(2) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "irange with any_matcher as max is transformed properly" do - source = <<~HEREDOC - (1.._) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "irange with method call as max is transformed properly" do - source = <<~HEREDOC - (1..max_value) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(1).at_most(max_value) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "erange with ints is transformed properly" do - source = <<~HEREDOC - (1...3) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(1).at_most(3 - 1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "erange with any_matcher as min is transformed properly" do - source = <<~HEREDOC - (_...3) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(0).at_most(3 - 1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "erange with method call as min is transformed properly" do - source = <<~HEREDOC - (min_value...3) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(min_value).at_most(3 - 1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "erange with any_matcher as max is transformed properly" do - source = <<~HEREDOC - (1..._) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "erange with method call as max is transformed properly" do - source = <<~HEREDOC - (1...max_value) * receiver.message - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(1).at_most(max_value - 1) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "lhs with multiple children raises" do - source = <<~HEREDOC - (1; 2) * receiver.message - HEREDOC - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - transform(source) - end - - assert_match /tmp:1:1/, e.message - end - - test "irange with invalid node as min raises" do - source = <<~HEREDOC - ("abc"..2) * receiver.message - HEREDOC - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - transform(source) - end - - assert_match /tmp:1:2/, e.message - end - - test "irange with invalid node as max raises" do - source = <<~HEREDOC - (1.."abc") * receiver.message - HEREDOC - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - transform(source) - end - - assert_match /tmp:1:5/, e.message - end - - test "lhs with begin node and not a range raises" do - source = <<~HEREDOC - (1) * receiver.message - HEREDOC - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - transform(source) - end - - assert_match /tmp:1:1/, e.message - end - - test "lhs with invalid node raises" do - source = <<~HEREDOC - "abc" * receiver.message - HEREDOC - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - transform(source) - end - - assert_match /tmp:1:1/, e.message - end - - test "rhs with invalid node raises" do - source = <<~HEREDOC - 1 * "abc" - HEREDOC - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - transform(source) - end - - assert_match /tmp:1:5/, e.message - end - - test ">> stubs return value on interaction without params" do - source = <<~HEREDOC - 1 * receiver.message >> "result" - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).times(1).returns("result") - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test ">> stubs return value on interaction with params" do - source = <<~HEREDOC - 1 * receiver.message(param1, param2) >> "result" - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).with(param1, param2).times(1).returns("result") - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test ">> stubs return value with complex expression" do - source = <<~HEREDOC - 1 * receiver.message >> [1, 2, 3] - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).times(1).returns([1, 2, 3]) - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test ">> stubs return value with range cardinality" do - source = <<~HEREDOC - (1..3) * receiver.message >> "result" - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(1).at_most(3).returns("result") - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test ">> stubs return value with any matcher cardinality" do - source = <<~HEREDOC - _ * receiver.message >> "result" - HEREDOC - - expected = <<~HEREDOC - receiver.expects(:message).at_least(0).returns("result") - HEREDOC - - assert_equal strip_end_line(expected), transform(source) - end - - test "interaction without >> has no returns call" do - source = <<~HEREDOC - 1 * receiver.message - HEREDOC - - result = transform(source) - refute_match(/returns/, result) - end - - test ">> is detected as interaction node" do - source = '1 * receiver.message >> "result"' - ast = ASTTransform::Transformer.new.build_ast(source) - assert @transformation.interaction_node?(ast) - end - - test "bare >> without interaction LHS is not detected as interaction" do - source = 'result >> "value"' - ast = ASTTransform::Transformer.new.build_ast(source) - refute @transformation.interaction_node?(ast) - end - - test "rhs without receiver raises" do - source = <<~HEREDOC - 1 * message - HEREDOC - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - transform(source) - end - - assert_match /tmp:1:5/, e.message - end - - private - - def transform(source, *transformations) - transformations << @transformation if transformations.empty? - super(source, *transformations) - end - end - end -end diff --git a/test/rspock/ast/parser/block_test.rb b/test/rspock/ast/parser/block_test.rb new file mode 100644 index 0000000..d04e539 --- /dev/null +++ b/test/rspock/ast/parser/block_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true +require 'test_helper' +require 'transformation_helper' +require 'rspock/ast/parser/block' + +module RSpock + module AST + module Parser + class BlockTest < Minitest::Test + extend RSpock::Declarative + include RSpock::Helpers::TransformationHelper + + def setup + @block = RSpock::AST::Parser::Block.new(:Start, nil) + @node = s(:send, nil, :a) + end + + test "#<< adds the given node" do + @block << @node + + assert_equal [@node], @block.children + end + + test "#range returns '?' if node does not contain range information" do + assert_equal '?', @block.range + end + + test "#successors returns empty array by default" do + assert_equal [], @block.successors + end + + test "#successors is frozen" do + assert_equal true, @block.successors.frozen? + end + + test "#children is duped" do + original = @block.children + + modified = @block.children + modified << @node + + refute_same original, modified + refute_equal original, modified + end + + test "#valid_successor? returns true if block passed is a valid successor" do + block = RSpock::AST::Parser::Block.new(:Start, nil) + def block.successors + [:Next] + end + + next_block = RSpock::AST::Parser::Block.new(:Next, nil) + + assert_equal true, block.valid_successor?(next_block) + end + + test "#valid_successor? returns false if block passed is not a valid successor" do + dummy_block = RSpock::AST::Parser::Block.new(:DummyType, nil) + + assert_equal false, @block.valid_successor?(dummy_block) + end + + test "#succession_error_msg returns the correct error message" do + expected = "Block Start @ ? must be followed by one of these Blocks: []" + + assert_equal expected, @block.succession_error_msg + end + + test "#can_start? returns false by default" do + assert_equal false, @block.can_start? + end + + test "#can_end? returns false by default" do + assert_equal false, @block.can_end? + end + end + end + end +end diff --git a/test/rspock/ast/parser/cleanup_block_test.rb b/test/rspock/ast/parser/cleanup_block_test.rb new file mode 100644 index 0000000..00eda55 --- /dev/null +++ b/test/rspock/ast/parser/cleanup_block_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/cleanup_block' + +module RSpock + module AST + module Parser + class CleanupBlockTest < Minitest::Test + extend RSpock::Declarative + + def setup + @block = RSpock::AST::Parser::CleanupBlock.new(nil) + end + + test "#can_start? returns false" do + assert_equal false, @block.can_start? + end + + test "#can_end? returns true" do + assert_equal true, @block.can_end? + end + + test "#successors returns the correct successors" do + assert_equal [:Where], @block.successors + end + + test "#successors is frozen" do + assert_equal true, @block.successors.frozen? + end + + test "#type is :Cleanup" do + assert_equal :Cleanup, @block.type + end + end + end + end +end diff --git a/test/rspock/ast/parser/expect_block_test.rb b/test/rspock/ast/parser/expect_block_test.rb new file mode 100644 index 0000000..993f366 --- /dev/null +++ b/test/rspock/ast/parser/expect_block_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/expect_block' + +module RSpock + module AST + module Parser + class ExpectBlockTest < Minitest::Test + extend RSpock::Declarative + include ASTTransform::TransformationHelper + + def setup + @block = RSpock::AST::Parser::ExpectBlock.new(nil) + end + + test "#can_start? returns true" do + assert_equal true, @block.can_start? + end + + test "#can_end? returns true" do + assert_equal true, @block.can_end? + end + + test "#successors returns the correct successors" do + assert_equal [:Cleanup, :Where], @block.successors + end + + test "#successors is frozen" do + assert_equal true, @block.successors.frozen? + end + + test "#type is :Expect" do + assert_equal :Expect, @block.type + end + + test "#children returns raw children without transformation" do + comparison = s(:send, 1, :==, 2) + @block << comparison + + actual = @block.children + assert_equal [comparison], actual + end + + test "#to_rspock_node returns :rspock_expect node with 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 + end + end + end + end +end diff --git a/test/rspock/ast/parser/given_block_test.rb b/test/rspock/ast/parser/given_block_test.rb new file mode 100644 index 0000000..956540e --- /dev/null +++ b/test/rspock/ast/parser/given_block_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/given_block' + +module RSpock + module AST + module Parser + class GivenBlockTest < Minitest::Test + extend RSpock::Declarative + + def setup + @block = RSpock::AST::Parser::GivenBlock.new(nil) + end + + test "#can_start? returns true" do + assert_equal true, @block.can_start? + end + + test "#can_end? returns false" do + assert_equal false, @block.can_end? + end + + test "#successors returns the correct successors" do + assert_equal [:When, :Expect], @block.successors + end + + test "#successors is frozen" do + assert_equal true, @block.successors.frozen? + end + + test "#type is :Given" do + assert_equal :Given, @block.type + end + end + end + end +end diff --git a/test/rspock/ast/parser/interaction_parser_test.rb b/test/rspock/ast/parser/interaction_parser_test.rb new file mode 100644 index 0000000..ac233d6 --- /dev/null +++ b/test/rspock/ast/parser/interaction_parser_test.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/interaction_parser' + +module RSpock + module AST + module Parser + class InteractionParserTest < Minitest::Test + extend RSpock::Declarative + include ASTTransform::TransformationHelper + + def setup + @transformer = ASTTransform::Transformer.new + @parser = InteractionParser.new + end + + # --- interaction_node? --- + + test "#interaction_node? returns true for basic interaction" do + ast = build_ast('1 * receiver.message') + assert @parser.interaction_node?(ast) + end + + test "#interaction_node? returns true for >> interaction" do + ast = build_ast('1 * receiver.message >> "result"') + assert @parser.interaction_node?(ast) + end + + test "#interaction_node? returns false for non-interaction" do + ast = build_ast('1 + 2') + refute @parser.interaction_node?(ast) + end + + test "#interaction_node? returns false for nil" do + refute @parser.interaction_node?(nil) + end + + test "#interaction_node? returns false for bare >>" do + ast = build_ast('result >> "value"') + refute @parser.interaction_node?(ast) + end + + # --- parse: basic structure --- + + test "#parse returns :rspock_interaction node" do + ast = build_ast('1 * receiver.message') + ir = @parser.parse(ast) + + assert_equal :rspock_interaction, ir.type + assert_equal 6, ir.children.length + end + + test "#parse returns node unchanged for non-interaction" do + ast = build_ast('1 + 2') + result = @parser.parse(ast) + assert_equal ast, result + end + + # --- parse: cardinality --- + + test "#parse extracts integer cardinality" do + ir = parse('1 * receiver.message') + assert_equal s(:int, 1), ir.children[0] + end + + test "#parse extracts _ any matcher cardinality" do + ir = parse('_ * receiver.message') + assert_equal s(:send, nil, :_), ir.children[0] + end + + test "#parse extracts irange cardinality" do + ir = parse('(1..3) * receiver.message') + cardinality = ir.children[0] + assert_equal :begin, cardinality.type + assert_equal :irange, cardinality.children[0].type + end + + test "#parse extracts erange cardinality" do + ir = parse('(1...3) * receiver.message') + cardinality = ir.children[0] + assert_equal :begin, cardinality.type + assert_equal :erange, cardinality.children[0].type + end + + # --- parse: receiver and message --- + + test "#parse extracts receiver" do + ir = parse('1 * receiver.message') + assert_equal s(:send, nil, :receiver), ir.children[1] + end + + test "#parse extracts message as symbol" do + ir = parse('1 * receiver.message') + assert_equal s(:sym, :message), ir.children[2] + end + + test "#parse extracts chained receiver" do + ir = parse('1 * base.receiver.message') + receiver = ir.children[1] + assert_equal :send, receiver.type + assert_equal :receiver, receiver.children[1] + end + + # --- parse: arguments --- + + test "#parse sets args to nil when no arguments" do + ir = parse('1 * receiver.message') + assert_nil ir.children[3] + end + + test "#parse wraps args in :array node" do + ir = parse('1 * receiver.message(param1, param2)') + args = ir.children[3] + assert_equal :array, args.type + assert_equal 2, args.children.length + end + + # --- parse: return value --- + + test "#parse sets return_value to nil without >>" do + ir = parse('1 * receiver.message') + assert_nil ir.children[4] + end + + test "#parse extracts return value from >>" do + ir = parse('1 * receiver.message >> "result"') + assert_equal s(:str, "result"), ir.children[4] + end + + # --- parse: block pass --- + + test "#parse sets block_pass to nil without &" do + ir = parse('1 * receiver.message') + assert_nil ir.children[5] + end + + test "#parse extracts &block_pass" do + ir = parse('1 * receiver.message(&my_proc)') + block_pass = ir.children[5] + assert_equal :block_pass, block_pass.type + end + + test "#parse separates &block from regular args" do + ir = parse('1 * receiver.message("arg", &my_proc)') + args = ir.children[3] + block_pass = ir.children[5] + + assert_equal 1, args.children.length + assert_equal :block_pass, block_pass.type + end + + # --- parse: errors --- + + test "#parse raises on inline do...end block" do + ast = build_ast('1 * receiver.message("arg") do; end') + e = assert_raises InteractionParser::InteractionError do + @parser.parse(ast) + end + assert_match(/Inline blocks/, e.message) + end + + test "#parse raises on inline { } block" do + ast = build_ast('1 * receiver.message("arg") { }') + e = assert_raises InteractionParser::InteractionError do + @parser.parse(ast) + end + assert_match(/Inline blocks/, e.message) + end + + test "#parse raises on rhs without receiver" do + ast = build_ast('1 * message') + e = assert_raises InteractionParser::InteractionError do + @parser.parse(ast) + end + assert_match(/must have a receiver/, e.message) + end + + test "#parse raises on invalid cardinality" do + ast = build_ast('"abc" * receiver.message') + assert_raises InteractionParser::InteractionError do + @parser.parse(ast) + end + end + + test "#parse raises on begin with multiple children" do + ast = build_ast('(1; 2) * receiver.message') + assert_raises InteractionParser::InteractionError do + @parser.parse(ast) + end + end + + test "#parse raises on irange with invalid min" do + ast = build_ast('("abc"..2) * receiver.message') + assert_raises InteractionParser::InteractionError do + @parser.parse(ast) + end + end + + test "#parse raises on irange with invalid max" do + ast = build_ast('(1.."abc") * receiver.message') + assert_raises InteractionParser::InteractionError do + @parser.parse(ast) + end + end + + private + + def build_ast(source) + @transformer.build_ast(source) + end + + def parse(source) + @parser.parse(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 new file mode 100644 index 0000000..71cafff --- /dev/null +++ b/test/rspock/ast/parser/then_block_test.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/then_block' + +module RSpock + module AST + module Parser + class ThenBlockTest < Minitest::Test + extend RSpock::Declarative + include ASTTransform::TransformationHelper + + INTERACTION_NODE = s(:send, s(:int, 1), :*, s(:send, :receiver, :message)) + + def setup + @block = RSpock::AST::Parser::ThenBlock.new(nil) + @transformer = ASTTransform::Transformer.new + end + + test "#can_start? returns false" do + assert_equal false, @block.can_start? + end + + test "#can_end? returns true" do + assert_equal true, @block.can_end? + end + + test "#successors returns the correct successors" do + assert_equal [:Cleanup, :Where], @block.successors + end + + test "#successors is frozen" do + assert_equal true, @block.successors.frozen? + end + + test "#type is :Then" do + assert_equal :Then, @block.type + end + + test "#children returns raw children without transformation" do + comparison = s(:send, 1, :==, 2) + @block << comparison + @block << INTERACTION_NODE + + assert_equal [comparison, INTERACTION_NODE], @block.children + end + + test "#to_rspock_node returns :rspock_then node" do + @block << s(:send, 1, :==, 2) + + ir = @block.to_rspock_node + assert_equal :rspock_then, ir.type + assert_equal 1, ir.children.length + assert_equal :send, ir.children[0].type + end + + test "#to_rspock_node converts interaction nodes to :rspock_interaction" do + node = @transformer.build_ast('1 * receiver.message') + @block << node + + ir = @block.to_rspock_node + assert_equal :rspock_then, ir.type + assert_equal 1, ir.children.length + 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 + + ir = @block.to_rspock_node + assert_equal comparison, ir.children[0] + end + + test "#to_rspock_node parses interaction with correct structure" do + node = @transformer.build_ast('1 * receiver.message("arg")') + @block << node + + ir = @block.to_rspock_node + interaction = ir.children[0] + + assert_equal :rspock_interaction, interaction.type + + cardinality = interaction.children[0] + assert_equal s(:int, 1), cardinality + + receiver = interaction.children[1] + assert_equal :send, receiver.type + + message = interaction.children[2] + assert_equal s(:sym, :message), message + + args = interaction.children[3] + assert_equal :array, args.type + assert_equal 1, args.children.length + end + + test "#to_rspock_node parses interaction with &block" do + node = @transformer.build_ast('1 * receiver.message(&my_proc)') + @block << node + + ir = @block.to_rspock_node + interaction = ir.children[0] + + assert_equal :rspock_interaction, interaction.type + + block_pass = interaction.children[5] + refute_nil block_pass + assert_equal :block_pass, block_pass.type + end + + test "#to_rspock_node parses interaction with >> return value" do + node = @transformer.build_ast('1 * receiver.message >> "result"') + @block << node + + ir = @block.to_rspock_node + interaction = ir.children[0] + + assert_equal :rspock_interaction, interaction.type + + return_value = interaction.children[4] + refute_nil return_value + assert_equal :str, return_value.type + end + + test "#to_rspock_node handles mixed interaction and comparison nodes" do + @block << s(:send, 1, :==, 2) + node = @transformer.build_ast('1 * receiver.message(&my_proc)') + @block << node + + ir = @block.to_rspock_node + assert_equal 2, ir.children.length + assert_equal :send, ir.children[0].type + assert_equal :rspock_interaction, ir.children[1].type + end + + test "#to_rspock_node with multiple interactions has correct indices" do + node0 = @transformer.build_ast('1 * receiver.method1(&proc1)') + node1 = @transformer.build_ast('1 * receiver.method2(&proc2)') + @block << node0 + @block << node1 + + ir = @block.to_rspock_node + assert_equal 2, ir.children.length + assert_equal :rspock_interaction, ir.children[0].type + assert_equal :rspock_interaction, ir.children[1].type + + assert_equal s(:sym, :method1), ir.children[0].children[2] + assert_equal s(:sym, :method2), ir.children[1].children[2] + end + end + end + end +end diff --git a/test/rspock/ast/parser/when_block_test.rb b/test/rspock/ast/parser/when_block_test.rb new file mode 100644 index 0000000..0c16349 --- /dev/null +++ b/test/rspock/ast/parser/when_block_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'test_helper' +require 'rspock/ast/parser/when_block' + +module RSpock + module AST + module Parser + class WhenBlockTest < Minitest::Test + extend RSpock::Declarative + + def setup + @block = RSpock::AST::Parser::WhenBlock.new(nil) + end + + test "#can_start? returns true" do + assert_equal true, @block.can_start? + end + + test "#can_end? returns false" do + assert_equal false, @block.can_end? + end + + test "#successors returns the correct successors" do + assert_equal [:Then], @block.successors + end + + test "#successors is frozen" do + assert_equal true, @block.successors.frozen? + end + + test "#type is :When" do + assert_equal :When, @block.type + end + end + end + end +end diff --git a/test/rspock/ast/parser/where_block_test.rb b/test/rspock/ast/parser/where_block_test.rb new file mode 100644 index 0000000..6eab1c9 --- /dev/null +++ b/test/rspock/ast/parser/where_block_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true +require 'test_helper' +require 'transformation_helper' +require 'ast_transform/transformation_helper' +require 'rspock/ast/parser/where_block' + +module RSpock + module AST + module Parser + class WhereBlockTest < Minitest::Test + extend RSpock::Declarative + include RSpock::Helpers::TransformationHelper + + def setup + @block = RSpock::AST::Parser::WhereBlock.new(nil) + end + + test "#can_start? returns false" do + assert_equal false, @block.can_start? + end + + test "#can_end? returns true" do + assert_equal true, @block.can_end? + end + + test "#type is :Where" do + assert_equal :Where, @block.type + end + + test "#header with a single send ast node returns that node's symbol" do + @block << s(:send, nil, :a) + + assert_equal [:a], @block.header + end + + test "#header separated by pipes returns each node's symbol" do + @block << s(:send, + s(:send, + s(:send, nil, :a), :|, s(:send, nil, :b) + ), + :|, s(:send, nil, :c)) + + assert_equal [:a, :b, :c], @block.header + end + + test "#header with a single send ast node must be a header node" do + @block << s(:str, "potato") + + assert_raises RSpock::AST::Parser::WhereBlock::MalformedError do + @block.header + end + end + + test "#header terminal nodes must be header nodes" do + @block << s(:send, + s(:str, "potato"), :|, s(:send, nil, :b)) + + assert_raises RSpock::AST::Parser::WhereBlock::MalformedError do + @block.header + end + end + + test "#data with a single data node returns that data node" do + @block << s(:send, nil, :a) + @block << s(:int, 1) + + assert_equal [[s(:int, 1)]], @block.data + end + + test "#data without data nodes returns an empty array" do + @block << s(:send, nil, :a) + + assert_equal [], @block.data + end + + test "#data with multiple rows returns multiple rows" do + @block << s(:send, nil, :a) + @block << s(:int, 1) + @block << s(:int, 2) + + expected = [ + [s(:int, 1)], + [s(:int, 2)] + ] + + assert_equal expected, @block.data + end + + test "#data pipes returns a flattened array of nodes" do + @block << s(:send, nil, :a) + @block << s(:send, s(:int, 1), :|, s(:int, 2)) + @block << s(:send, s(:send, s(:int, 1), :|, s(:int, 2)), :|, s(:send, nil, :method_call)) + + expected = [ + [s(:int, 1), s(:int, 2)], + [s(:int, 1), s(:int, 2), s(:send, nil, :method_call)], + ] + + assert_equal expected, @block.data + end + + test "#data pipes works when subtracting from nodes" do + @block << s(:send, s(:send, nil, :a), :|, s(:send, nil, :b)) + @block << s(:send, s(:send, s(:int, 2), :-, s(:int, 1)), :|, s(:int, 2)) + + expected = [ + [ s(:send, s(:int, 2), :-, s(:int, 1)), s(:int, 2)], + ] + + assert_equal expected, @block.data + end + + test "#to_rspock_node returns a self-contained :rspock_where node" do + @block << s(:send, s(:send, nil, :a), :|, s(:send, nil, :b)) + @block << s(:send, s(:int, 1), :|, s(:int, 2)) + @block << s(:send, s(:int, 3), :|, s(:int, 4)) + + node = @block.to_rspock_node + + assert_equal :rspock_where, node.type + + header_node = node.children[0] + assert_equal :rspock_where_header, header_node.type + assert_equal [s(:sym, :a), s(:sym, :b)], header_node.children + + data_rows = node.children[1..] + assert_equal 2, data_rows.length + assert_equal s(:array, s(:int, 1), s(:int, 2)), data_rows[0] + assert_equal s(:array, s(:int, 3), s(:int, 4)), data_rows[1] + end + + test "#to_rspock_node with single column" do + @block << s(:send, nil, :a) + @block << s(:int, 1) + + node = @block.to_rspock_node + + assert_equal :rspock_where, node.type + assert_equal s(:rspock_where_header, s(:sym, :a)), node.children[0] + assert_equal [s(:array, s(:int, 1))], node.children[1..] + end + end + end + end +end diff --git a/test/rspock/ast/start_block_test.rb b/test/rspock/ast/start_block_test.rb deleted file mode 100644 index 79891e6..0000000 --- a/test/rspock/ast/start_block_test.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'transformation_helper' -require 'rspock/ast/start_block' - -module RSpock - module AST - class StartBlockTest < Minitest::Test - extend RSpock::Declarative - include RSpock::Helpers::TransformationHelper - - def setup - @block = RSpock::AST::StartBlock.new(nil) - end - - test "#node_container? returns false by default" do - assert_equal false, @block.node_container? - end - - test "#successors returns the correct successors when children are empty" do - assert_equal false, @block.node_container? - - assert_equal [:Given, :When, :Expect], @block.successors - end - - test "#successors returns the correct successors when block contains children" do - @block.node_container = true - @block << s(:send, nil, :a) - - assert_equal [:End], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :End" do - assert_equal :Start, @block.type - end - end - end -end diff --git a/test/rspock/ast/then_block_test.rb b/test/rspock/ast/then_block_test.rb deleted file mode 100644 index e870294..0000000 --- a/test/rspock/ast/then_block_test.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'rspock/ast/then_block' - -module RSpock - module AST - class ThenBlockTest < Minitest::Test - extend RSpock::Declarative - include ASTTransform::TransformationHelper - - INTERACTION_NODE = s(:send, s(:int, 1), :*, s(:send, :receiver, :message)) - - def setup - @block = RSpock::AST::ThenBlock.new(nil) - end - - test "#node_container? returns true by default" do - assert_equal true, @block.node_container? - end - - test "#successors returns the correct successors" do - assert_equal [:Cleanup, :Where, :End], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :Then" do - assert_equal :Then, @block.type - end - - test "#children returns transformed children when comparing with == or !=" do - @block << s(:send, 1, :==, 2) - @block << s(:send, 1, :!=, 2) - - actual = @block.children - expected = [ - s(:send, nil, :assert_equal, 2, 1), - s(:send, nil, :refute_equal, 2, 1) - ] - - assert_equal expected, actual - end - - test "#children ignores _test_index_ and _line_number_ comparisons when comparing with == or !=" do - test_index_ast = s(:send, 1, :==, s(:send, nil, :_test_index_)) - line_number_ast = s(:send, 1, :!=, s(:send, nil, :_line_number_)) - - @block << test_index_ast - @block << line_number_ast - - actual = @block.children - expected = [test_index_ast, line_number_ast] - - assert_equal expected, actual - end - - test "#children ignores interaction nodes" do - @block << s(:send, 1, :==, 2) - @block << INTERACTION_NODE - - actual = @block.children - expected = [ - s(:send, nil, :assert_equal, 2, 1), - ] - - assert_equal expected, actual - end - - test "#interactions returns transformed interaction nodes" do - @block << s(:send, 1, :==, 2) - @block << INTERACTION_NODE - - actual = @block.interactions - expected = [ - s(:send, - s(:send, - :receiver, - :expects, - s(:sym, :message) - ), - :times, - s(:int, 1) - ) - ] - - assert_equal expected, actual - end - end - end -end diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index 8b2498f..799da93 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true require 'test_helper' -require 'string_helper' +require 'transformation_helper' module RSpock module AST @@ -21,11 +21,11 @@ def setup end HEREDOC - error = assert_raises RSpock::AST::BlockError do + error = assert_raises RSpock::AST::Parser::BlockError do transform(source) end - assert_equal "Test method @ tmp:1:1 must start with one of these Blocks: #{[:Given, :When, :Expect]}", error.message + assert_equal "Test method @ tmp:1:1 must start with one of: Given, When, Expect", error.message end test "first node cannot be regular code" do @@ -35,11 +35,11 @@ def setup end HEREDOC - error = assert_raises RSpock::AST::BlockError do + error = assert_raises RSpock::AST::Parser::BlockError do transform(source) end - assert_equal "Test method @ tmp:1:1 must start with one of these Blocks: #{[:Given, :When, :Expect]}", error.message + assert_equal "Test method must start with one of: Given, When, Expect", error.message end test "first node can be regular code if strict mode is disabled" do @@ -53,7 +53,7 @@ def setup expected = <<~HEREDOC test("Adding 1 and 2 results in 3") { - (assert_equal(3, 1 + 2)) + assert_equal(3, 1 + 2) } HEREDOC @@ -67,11 +67,11 @@ def setup end HEREDOC - error = assert_raises RSpock::AST::BlockError do + error = assert_raises RSpock::AST::Parser::BlockError do transform(source) end - assert_equal "Test method @ tmp:1:1 must start with one of these Blocks: #{[:Given, :When, :Expect]}", error.message + assert_equal "Test method @ tmp:1:1 must start with one of: Given, When, Expect", error.message end test "expect block can be followed by nothing" do @@ -103,19 +103,13 @@ def setup end HEREDOC - start_block_class = Class.new(RSpock::AST::Block) do + block1_class = Class.new(RSpock::AST::Parser::Block) do def initialize(node) - super(:Start1, node) + super(:Block1, node) end - def successors - [:Block1, :Block2] - end - end - - block1_class = Class.new(RSpock::AST::Block) do - def initialize(node) - super(:Block1, node) + def can_start? + true end def successors @@ -123,17 +117,21 @@ def successors end end - block2_class = Class.new(RSpock::AST::Block) do + block2_class = Class.new(RSpock::AST::Parser::Block) do def initialize(node) super(:Block2, node) end + + def can_end? + true + end end - source_map = { Block1: block1_class, Block2: block2_class } + block_registry = { Block1: block1_class, Block2: block2_class } transform( source, - RSpock::AST::Transformation.new(start_block_class: start_block_class, source_map: source_map), + RSpock::AST::Transformation.new(block_registry: block_registry), ) end @@ -145,38 +143,36 @@ def initialize(node) end HEREDOC - start_block_class = Class.new(RSpock::AST::Block) do + block1_class = Class.new(RSpock::AST::Parser::Block) do def initialize(node) - super(:Start1, node) + super(:Block1, node) end - def successors - [:Block1, :Block2] + def can_start? + true end end - block1_class = Class.new(RSpock::AST::Block) do + block2_class = Class.new(RSpock::AST::Parser::Block) do def initialize(node) - super(:Block1, node) + super(:Block2, node) end - end - block2_class = Class.new(RSpock::AST::Block) do - def initialize(node) - super(:Block2, node) + def can_end? + true end end - source_map = { Block1: block1_class, Block2: block2_class } + block_registry = { Block1: block1_class, Block2: block2_class } - error = assert_raises RSpock::AST::BlockError do + error = assert_raises RSpock::AST::Parser::BlockError do transform( source, - RSpock::AST::Transformation.new(start_block_class: start_block_class, source_map: source_map), + RSpock::AST::Transformation.new(block_registry: block_registry), ) end - assert_equal "Block Block1 @ tmp:2:3 must be followed by one of these Blocks: #{[:End]}", error.message + assert_equal "Block Block1 @ tmp:2:3 must be followed by one of these Blocks: #{[]}", error.message end test "#run adds extend RSpock::Declarative when using Class.new" do @@ -231,34 +227,32 @@ class Potato end HEREDOC - start_block_class = Class.new(RSpock::AST::Block) do + block1_class = Class.new(RSpock::AST::Parser::Block) do def initialize(node) - super(:Start1, node) + super(:Block1, node) end - def successors - [:Block1, :Block2] + def can_start? + true end end - block1_class = Class.new(RSpock::AST::Block) do + block2_class = Class.new(RSpock::AST::Parser::Block) do def initialize(node) - super(:Block1, node) + super(:Block2, node) end - end - block2_class = Class.new(RSpock::AST::Block) do - def initialize(node) - super(:Block2, node) + def can_end? + true end end - source_map = { Block1: block1_class, Block2: block2_class } + block_registry = { Block1: block1_class, Block2: block2_class } - assert_raises RSpock::AST::BlockError do + assert_raises RSpock::AST::Parser::BlockError do transform( source, - RSpock::AST::Transformation.new(start_block_class: start_block_class, source_map: source_map), + RSpock::AST::Transformation.new(block_registry: block_registry), ) end end @@ -371,6 +365,139 @@ def initialize(node) assert_equal strip_end_line(expected), transform(source) end + test "test with interaction and &block forwarding" do + source = <<~HEREDOC + test "block forwarding" do + Given + my_proc = Proc.new { } + dep = mock + + When + dep.call_method("arg", &my_proc) + + Then + 1 * dep.call_method("arg", &my_proc) + end + HEREDOC + + expected = <<~HEREDOC + test("block forwarding") { + my_proc = Proc.new { + } + dep = mock + dep.expects(:call_method).with("arg").times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method) + dep.call_method("arg", &my_proc) + assert_same(my_proc, __rspock_blk_0.call) + } + HEREDOC + + assert_equal strip_end_line(expected), transform(source) + end + + test "test with multiple &block interactions" do + source = <<~HEREDOC + test "multiple blocks" do + Given + cb1 = Proc.new { } + cb2 = Proc.new { } + dep = mock + + When + dep.method1(&cb1) + dep.method2(&cb2) + + Then + 1 * dep.method1(&cb1) + 1 * dep.method2(&cb2) + end + HEREDOC + + expected = <<~HEREDOC + test("multiple blocks") { + cb1 = Proc.new { + } + cb2 = Proc.new { + } + dep = mock + dep.expects(:method1).times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) + dep.expects(:method2).times(1) + __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2) + dep.method1(&cb1) + dep.method2(&cb2) + assert_same(cb1, __rspock_blk_0.call) + assert_same(cb2, __rspock_blk_1.call) + } + HEREDOC + + assert_equal strip_end_line(expected), transform(source) + end + + test "test with &block and >> return value" do + source = <<~HEREDOC + test "block with return" do + Given + my_proc = Proc.new { } + dep = mock + + When + dep.call_method(&my_proc) + + Then + 1 * dep.call_method(&my_proc) >> "result" + end + HEREDOC + + expected = <<~HEREDOC + test("block with return") { + my_proc = Proc.new { + } + dep = mock + dep.expects(:call_method).times(1).returns("result") + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method) + dep.call_method(&my_proc) + assert_same(my_proc, __rspock_blk_0.call) + } + HEREDOC + + assert_equal strip_end_line(expected), transform(source) + end + + test "test with mixed interactions (with and without &block)" do + source = <<~HEREDOC + test "mixed interactions" do + Given + my_proc = Proc.new { } + dep = mock + + When + dep.method1("arg") + dep.method2(&my_proc) + + Then + 1 * dep.method1("arg") + 1 * dep.method2(&my_proc) + end + HEREDOC + + expected = <<~HEREDOC + test("mixed interactions") { + my_proc = Proc.new { + } + dep = mock + dep.expects(:method1).with("arg").times(1) + dep.expects(:method2).times(1) + __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2) + dep.method1("arg") + dep.method2(&my_proc) + assert_same(my_proc, __rspock_blk_1.call) + } + HEREDOC + + assert_equal strip_end_line(expected), transform(source) + end + private def transform(source, *transformations) diff --git a/test/rspock/ast/when_block_test.rb b/test/rspock/ast/when_block_test.rb deleted file mode 100644 index 3a0ff4a..0000000 --- a/test/rspock/ast/when_block_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'rspock/ast/when_block' - -module RSpock - module AST - class WhenBlockTest < Minitest::Test - extend RSpock::Declarative - - def setup - @block = RSpock::AST::WhenBlock.new(nil) - end - - test "#node_container? returns true by default" do - assert_equal true, @block.node_container? - end - - test "#successors returns the correct successors" do - assert_equal [:Then], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :When" do - assert_equal :When, @block.type - end - end - end -end diff --git a/test/rspock/ast/where_block_test.rb b/test/rspock/ast/where_block_test.rb deleted file mode 100644 index d199fdf..0000000 --- a/test/rspock/ast/where_block_test.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'transformation_helper' -require 'ast_transform/transformation_helper' -require 'rspock/ast/where_block' - -module RSpock - module AST - class WhereBlockTest < Minitest::Test - extend RSpock::Declarative - include RSpock::Helpers::TransformationHelper - - def setup - @block = RSpock::AST::WhereBlock.new(nil) - end - - test "#node_container? returns true by default" do - assert_equal true, @block.node_container? - end - - test "#successors returns the correct successors" do - assert_equal [:End], @block.successors - end - - test "#successors is frozen" do - assert_equal true, @block.successors.frozen? - end - - test "#type is :End" do - assert_equal :Where, @block.type - end - - test "#header with a single send ast node returns that node's symbol" do - @block << s(:send, nil, :a) - - assert_equal [:a], @block.header - end - - test "#header separated by pipes returns each node's symbol" do - @block << s(:send, - s(:send, - s(:send, nil, :a), :|, s(:send, nil, :b) - ), - :|, s(:send, nil, :c)) - - assert_equal [:a, :b, :c], @block.header - end - - test "#header with a single send ast node must be a header node" do - @block << s(:str, "potato") - - assert_raises RSpock::AST::WhereBlock::MalformedError do - @block.header - end - end - - test "#header terminal nodes must be header nodes" do - @block << s(:send, - s(:str, "potato"), :|, s(:send, nil, :b)) - - assert_raises RSpock::AST::WhereBlock::MalformedError do - @block.header - end - end - - test "#data with a single data node returns that data node" do - @block << s(:send, nil, :a) - @block << s(:int, 1) - - assert_equal [[s(:int, 1)]], @block.data - end - - test "#data without data nodes returns an empty array" do - @block << s(:send, nil, :a) - - assert_equal [], @block.data - end - - test "#data with multiple rows returns multiple rows" do - @block << s(:send, nil, :a) - @block << s(:int, 1) - @block << s(:int, 2) - - expected = [ - [s(:int, 1)], - [s(:int, 2)] - ] - - assert_equal expected, @block.data - end - - test "#data pipes returns a flattened array of nodes" do - @block << s(:send, nil, :a) - @block << s(:send, s(:int, 1), :|, s(:int, 2)) - @block << s(:send, s(:send, s(:int, 1), :|, s(:int, 2)), :|, s(:send, nil, :method_call)) - - expected = [ - [s(:int, 1), s(:int, 2)], - [s(:int, 1), s(:int, 2), s(:send, nil, :method_call)], - ] - - assert_equal expected, @block.data - end - - test "#data pipes works when subtracting from nodes" do - @block << s(:send, s(:send, nil, :a), :|, s(:send, nil, :b)) - @block << s(:send, s(:send, s(:int, 2), :-, s(:int, 1)), :|, s(:int, 2)) - - expected = [ - [ s(:send, s(:int, 2), :-, s(:int, 1)), s(:int, 2)], - ] - - assert_equal expected, @block.data - end - end - end -end diff --git a/test/rspock/helpers/block_capture_test.rb b/test/rspock/helpers/block_capture_test.rb new file mode 100644 index 0000000..937090a --- /dev/null +++ b/test/rspock/helpers/block_capture_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "test_helper" +require "rspock/helpers/block_capture" + +class RealCollaborator + def frame(title, &block) + block.call("from_real") if block + "real_return" + end +end + +class BlockCaptureTest < Minitest::Test + # --- Mock objects --- + + def test_mock_captures_exact_block + m = mock + m.expects(:frame).with("Build").once + getter = RSpock::Helpers::BlockCapture.capture(m, :frame) + + my_block = Proc.new { "hello" } + m.frame("Build", &my_block) + + assert_same my_block, getter.call + end + + def test_mock_nil_when_no_block_passed + m = mock + m.expects(:frame).with("Build").once + getter = RSpock::Helpers::BlockCapture.capture(m, :frame) + + m.frame("Build") + + assert_nil getter.call + end + + def test_mock_does_not_capture_from_other_methods + m = mock + m.expects(:other_method).once + getter = RSpock::Helpers::BlockCapture.capture(m, :frame) + + my_block = Proc.new { "hello" } + m.other_method(&my_block) + + assert_nil getter.call + end + + def test_mock_yields_still_works + m = mock + m.expects(:frame).with("Build").yields("yielded_arg") + getter = RSpock::Helpers::BlockCapture.capture(m, :frame) + + received = nil + my_block = Proc.new { |x| received = x } + m.frame("Build", &my_block) + + assert_same my_block, getter.call + assert_equal "yielded_arg", received + end + + def test_mock_returns_still_works + m = mock + m.expects(:frame).with("Build").returns("mock_result") + getter = RSpock::Helpers::BlockCapture.capture(m, :frame) + + my_block = Proc.new { } + result = m.frame("Build", &my_block) + + assert_same my_block, getter.call + assert_equal "mock_result", result + end + + # --- Real objects --- + + def test_real_object_captures_exact_block + obj = RealCollaborator.new + obj.expects(:frame).with("Build").once + getter = RSpock::Helpers::BlockCapture.capture(obj, :frame) + + my_block = Proc.new { |x| } + obj.frame("Build", &my_block) + + assert_same my_block, getter.call + end + + def test_real_object_nil_when_no_block_passed + obj = RealCollaborator.new + obj.expects(:frame).with("Build").once + getter = RSpock::Helpers::BlockCapture.capture(obj, :frame) + + obj.frame("Build") + + assert_nil getter.call + end + + def test_real_object_returns_still_works + obj = RealCollaborator.new + obj.expects(:frame).with("Build").returns("stubbed").once + getter = RSpock::Helpers::BlockCapture.capture(obj, :frame) + + my_block = Proc.new { } + result = obj.frame("Build", &my_block) + + assert_same my_block, getter.call + assert_equal "stubbed", result + end + + def test_real_object_with_stubs + obj = RealCollaborator.new + obj.stubs(:frame).returns("stubbed") + getter = RSpock::Helpers::BlockCapture.capture(obj, :frame) + + my_block = Proc.new { } + result = obj.frame("Build", &my_block) + + assert_same my_block, getter.call + assert_equal "stubbed", result + end +end