From aaac0e0c26ce27cb8a610be22358f33b6679af9d Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 27 Feb 2026 11:04:08 -0500 Subject: [PATCH 1/4] refactor: restructure blocks into Parser namespace and convert InteractionParser to class Move all block classes (Given, When, Then, Expect, Cleanup, Where) from RSpock::AST to RSpock::AST::Parser, making them parser-phase constructs rather than top-level AST components. Tests moved accordingly. Convert InteractionParser from a module with class methods to a proper class with instance methods for better encapsulation. Introduce can_start?/can_end? on Block subclasses as declarative boundary checks, eliminating the need for StartBlock/EndBlock sentinel constructs. Clean up Block base class by removing dead code (unshift, node_container). Include NodeBuilder in Block and InteractionParser so they produce RSpock AST nodes transparently via the s() helper override. Made-with: Cursor --- lib/rspock/ast/block.rb | 95 -------- lib/rspock/ast/cleanup_block.rb | 16 -- lib/rspock/ast/end_block.rb | 17 -- lib/rspock/ast/expect_block.rb | 21 -- lib/rspock/ast/given_block.rb | 16 -- lib/rspock/ast/parser/block.rb | 88 ++++++++ lib/rspock/ast/parser/cleanup_block.rb | 22 ++ lib/rspock/ast/parser/expect_block.rb | 26 +++ lib/rspock/ast/parser/given_block.rb | 22 ++ lib/rspock/ast/parser/interaction_parser.rb | 131 +++++++++++ lib/rspock/ast/parser/then_block.rb | 29 +++ lib/rspock/ast/parser/when_block.rb | 22 ++ lib/rspock/ast/parser/where_block.rb | 94 ++++++++ lib/rspock/ast/start_block.rb | 28 --- lib/rspock/ast/then_block.rb | 34 --- lib/rspock/ast/when_block.rb | 16 -- lib/rspock/ast/where_block.rb | 86 -------- test/rspock/ast/block_test.rb | 88 -------- test/rspock/ast/cleanup_block_test.rb | 27 --- test/rspock/ast/end_block_test.rb | 31 --- test/rspock/ast/expect_block_test.rb | 58 ----- test/rspock/ast/given_block_test.rb | 31 --- test/rspock/ast/parser/block_test.rb | 79 +++++++ test/rspock/ast/parser/cleanup_block_test.rb | 37 ++++ test/rspock/ast/parser/expect_block_test.rb | 55 +++++ test/rspock/ast/parser/given_block_test.rb | 37 ++++ .../ast/parser/interaction_parser_test.rb | 204 ++++++++++++++++++ test/rspock/ast/parser/then_block_test.rb | 153 +++++++++++++ test/rspock/ast/parser/when_block_test.rb | 37 ++++ test/rspock/ast/parser/where_block_test.rb | 145 +++++++++++++ test/rspock/ast/start_block_test.rb | 42 ---- test/rspock/ast/then_block_test.rb | 92 -------- test/rspock/ast/when_block_test.rb | 31 --- test/rspock/ast/where_block_test.rb | 117 ---------- 34 files changed, 1181 insertions(+), 846 deletions(-) delete mode 100644 lib/rspock/ast/block.rb delete mode 100644 lib/rspock/ast/cleanup_block.rb delete mode 100644 lib/rspock/ast/end_block.rb delete mode 100644 lib/rspock/ast/expect_block.rb delete mode 100644 lib/rspock/ast/given_block.rb create mode 100644 lib/rspock/ast/parser/block.rb create mode 100644 lib/rspock/ast/parser/cleanup_block.rb create mode 100644 lib/rspock/ast/parser/expect_block.rb create mode 100644 lib/rspock/ast/parser/given_block.rb create mode 100644 lib/rspock/ast/parser/interaction_parser.rb create mode 100644 lib/rspock/ast/parser/then_block.rb create mode 100644 lib/rspock/ast/parser/when_block.rb create mode 100644 lib/rspock/ast/parser/where_block.rb delete mode 100644 lib/rspock/ast/start_block.rb delete mode 100644 lib/rspock/ast/then_block.rb delete mode 100644 lib/rspock/ast/when_block.rb delete mode 100644 lib/rspock/ast/where_block.rb delete mode 100644 test/rspock/ast/block_test.rb delete mode 100644 test/rspock/ast/cleanup_block_test.rb delete mode 100644 test/rspock/ast/end_block_test.rb delete mode 100644 test/rspock/ast/expect_block_test.rb delete mode 100644 test/rspock/ast/given_block_test.rb create mode 100644 test/rspock/ast/parser/block_test.rb create mode 100644 test/rspock/ast/parser/cleanup_block_test.rb create mode 100644 test/rspock/ast/parser/expect_block_test.rb create mode 100644 test/rspock/ast/parser/given_block_test.rb create mode 100644 test/rspock/ast/parser/interaction_parser_test.rb create mode 100644 test/rspock/ast/parser/then_block_test.rb create mode 100644 test/rspock/ast/parser/when_block_test.rb create mode 100644 test/rspock/ast/parser/where_block_test.rb delete mode 100644 test/rspock/ast/start_block_test.rb delete mode 100644 test/rspock/ast/then_block_test.rb delete mode 100644 test/rspock/ast/when_block_test.rb delete mode 100644 test/rspock/ast/where_block_test.rb 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/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/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/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/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/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/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/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..226a66f --- /dev/null +++ b/test/rspock/ast/parser/interaction_parser_test.rb @@ -0,0 +1,204 @@ +# 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 + + 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/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 From 7559b4ce5692c59ff3d5ef37b46859685a684b89 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 27 Feb 2026 11:04:25 -0500 Subject: [PATCH 2/4] refactor: introduce RSpock AST node hierarchy and extract TestMethodParser Create a formal RSpock AST with typed node subclasses (TestNode, DefNode, GivenNode, WhenNode, ThenNode, ExpectNode, CleanupNode, WhereNode, InteractionNode) under RSpock::AST::Node, each with named accessors for their positional children. Override the s() helper via NodeBuilder to transparently construct the correct subclass for rspock_* types. Extract parsing logic from TestMethodTransformation into a dedicated TestMethodParser class, establishing a clean parse-then-transform pipeline. TestMethodTransformation now receives a fully formed RSpock AST (TestNode) and focuses solely on transformation. Rename source_map to block_registry (DEFAULT_BLOCK_REGISTRY) to accurately reflect its role as a keyword-to-block-class mapping, not source mapping. Made-with: Cursor --- .../comparison_to_assertion_transformation.rb | 2 +- lib/rspock/ast/node.rb | 101 ++++++++ lib/rspock/ast/parser/test_method_parser.rb | 98 ++++++++ lib/rspock/ast/test_method_transformation.rb | 230 +++++++++--------- lib/rspock/ast/transformation.rb | 40 ++- test/rspock/ast/transformation_test.rb | 223 +++++++++++++---- 6 files changed, 506 insertions(+), 188 deletions(-) create mode 100644 lib/rspock/ast/node.rb create mode 100644 lib/rspock/ast/parser/test_method_parser.rb 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/node.rb b/lib/rspock/ast/node.rb new file mode 100644 index 0000000..13ee4a6 --- /dev/null +++ b/lib/rspock/ast/node.rb @@ -0,0 +1,101 @@ +# 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.find { |n| n.type == :rspock_def } + end + + def where_node + children.find { |n| n.type == :rspock_where } + end + + def blocks + children.reject { |n| n.type == :rspock_def } + end + 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/test_method_parser.rb b/lib/rspock/ast/parser/test_method_parser.rb new file mode 100644 index 0000000..a4a57b4 --- /dev/null +++ b/lib/rspock/ast/parser/test_method_parser.rb @@ -0,0 +1,98 @@ +# 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_given, ...), + # s(:rspock_when, ...), + # s(:rspock_then, ...), + # s(:rspock_where, s(:rspock_where_header, ...), ...)) + 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) + rspock_children = [] + rspock_children << s(:rspock_def, node.children[0], node.children[1]) + blocks.each { |block| rspock_children << block.to_rspock_node } + s(:rspock_test, *rspock_children) + end + end + end + end +end diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index 72d3e1f..eb5ba3b 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -1,165 +1,165 @@ # 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 = [] - add_block(start_block) - test_method_nodes(node).each { |n| parse_node(n) } - add_block(@end_block_class.new) - nil - 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) + method_call = rspock_ast.def_node.method_call + method_args = rspock_ast.def_node.args + where = rspock_ast.where_node + + transformed_blocks = rspock_ast.blocks.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 - end - def build_where_block_iterator(rows) - s(:send, - s(:send, - s(:array, *build_where_block_data_rows(rows)), - :each, - ), - :with_index - ) + transformed_ast = rspock_ast.updated(nil, [rspock_ast.def_node, *transformed_blocks]) + build_ruby_ast(method_call, method_args, transformed_ast, where, hoisted_setups) end - def build_where_block_data_rows(rows) - rows.map(&method(:build_where_block_data_row)) - end + def transform_then_block(then_node, hoisted_setups) + interaction_setups = [] + then_children = [] - def build_where_block_data_row(row) - children = row.dup - children << s(:int, row.first&.loc&.expression&.line) + 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) - s(:array, *children) - end + interaction_setups << setup + then_children << assertion unless assertion.equal?(child) + else + then_children << @comparison_transformation.run(child) + end + end - def build_where_block_args(header) - injected_args = header.map { |column| s(:arg, column) } - injected_args << s(:arg, :_line_number_) + 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 - s(:args, - s(:mlhs, *injected_args), - s(:arg, :_test_index_), - ) + then_node.updated(nil, then_children) end - def build_test_method_def(node) - if where_block - ast = s(:block, - TestMethodDefTransformation.new.run(node.children[0]), - node.children[1], - build_test_body + def transform_expect_block(expect_node) + new_children = expect_node.children.map { |child| @comparison_transformation.run(child) } + expect_node.updated(nil, new_children) + end + + # --- Build final Ruby AST --- + + def build_ruby_ast(method_call, method_args, transformed_ast, where, hoisted_setups) + if where + test_def = s(:block, + TestMethodDefTransformation.new.run(method_call), + method_args, + build_test_body(transformed_ast, hoisted_setups) + ) + test_def = HeaderNodesTransformation.new(where.header).run(test_def) + + 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(transformed_ast, 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(transformed_ast, hoisted_setups) + body_children = [] + + transformed_ast.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, :rspock_where, :rspock_def + # handled separately + end end - @blocks << block - 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 + ast = s(:begin, *body_children) - def parse_node(node) - if @source_map.key?(node.children[1]) - add_block(build_block(node)) - else - current_scope << node + cleanup = transformed_ast.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/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/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) From 11467b0d45efeb0d74081094bf8105c656a37c66 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 27 Feb 2026 11:04:38 -0500 Subject: [PATCH 3/4] feat: add interaction transformations and block identity verification Split interaction handling into focused, single-responsibility transformers: - InteractionToMochaMockTransformation: converts interaction nodes to Mocha expects/stubs with cardinality, args, and return value support - InteractionToBlockIdentityAssertionTransformation: generates assert_same checks for &var block forwarding verification - InteractionBlockAssertionTransformation: detects interaction nodes within Then/Expect blocks and delegates to the above transformers Add BlockCapture helper module for capturing blocks passed to both mocked and real objects, enabling verification that the correct block was forwarded to collaborators. Support >> operator in interactions for stubbing return values, with top-level-only transformation to avoid rewriting nested >> calls. Document interaction syntax, block verification, and >> stubbing in README. Made-with: Cursor --- README.md | 42 ++++- ...eraction_block_assertion_transformation.rb | 50 ++++++ ...block_identity_assertion_transformation.rb | 30 ++++ ...nteraction_to_mocha_mock_transformation.rb | 104 +++++++++++ lib/rspock/ast/interaction_transformation.rb | 51 +++++- lib/rspock/helpers/block_capture.rb | 41 +++++ ..._identity_assertion_transformation_test.rb | 83 +++++++++ ...ction_to_mocha_mock_transformation_test.rb | 170 ++++++++++++++++++ .../ast/interaction_transformation_test.rb | 93 +++++++++- test/rspock/helpers/block_capture_test.rb | 119 ++++++++++++ 10 files changed, 772 insertions(+), 11 deletions(-) create mode 100644 lib/rspock/ast/interaction_block_assertion_transformation.rb create mode 100644 lib/rspock/ast/interaction_to_block_identity_assertion_transformation.rb create mode 100644 lib/rspock/ast/interaction_to_mocha_mock_transformation.rb create mode 100644 lib/rspock/helpers/block_capture.rb create mode 100644 test/rspock/ast/interaction_to_block_identity_assertion_transformation_test.rb create mode 100644 test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb create mode 100644 test/rspock/helpers/block_capture_test.rb 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/interaction_block_assertion_transformation.rb b/lib/rspock/ast/interaction_block_assertion_transformation.rb new file mode 100644 index 0000000..89e1167 --- /dev/null +++ b/lib/rspock/ast/interaction_block_assertion_transformation.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require 'ast_transform/abstract_transformation' + +module RSpock + module AST + # Transforms an interaction block into an assertion that checks if the block + # is the same as the captured block passed to the interaction. + # + # @example + # 1 * receiver.message(&my_proc) + # => assert_same(my_proc, __rspock_blk_0.call) + # + # @see InteractionTransformation + class InteractionBlockAssertionTransformation < ASTTransform::AbstractTransformation + # @param index [Integer] the index of the block capture variable + def initialize(index = 0) + @index = index + end + + def run(node) + block_pass = extract_block_pass(node) + return nil unless block_pass + + capture_var = :"__rspock_blk_#{@index}" + block_var = block_pass.children[0] + + s(:send, nil, :assert_same, + block_var, + s(:send, s(:lvar, capture_var), :call) + ) + end + + private + + def extract_block_pass(node) + # Unwrap >> return value + if node.type == :send && node.children[1] == :>> + node = node.children[0] + end + + return nil unless node.type == :send && node.children[1] == :* + + rhs = node.children[2] + return nil unless rhs.type == :send + + rhs.children[2..].find { |n| n.type == :block_pass } + 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 index e4ffb01..711d402 100644 --- a/lib/rspock/ast/interaction_transformation.rb +++ b/lib/rspock/ast/interaction_transformation.rb @@ -6,8 +6,14 @@ module AST class InteractionTransformation < ASTTransform::AbstractTransformation class InteractionError < RuntimeError; end + Interaction = Struct.new(:setup, :assertion) + + def initialize(index = 0) + @index = index + end + def run(node) - return node unless interaction_node?(node) + return Interaction.new(node, nil) unless interaction_node?(node) parse_node(node) transform_node @@ -49,7 +55,34 @@ def transform_node result = chain_call(result, :returns, @return_value_node) if @return_value_node - result + if @block_pass_node + build_block_pass_result(result) + else + Interaction.new(result, nil) + end + end + + def build_block_pass_result(expects_node) + capture_var = :"__rspock_blk_#{@index}" + block_var = @block_pass_node.children[0] + + capture_call = s(:lvasgn, capture_var, + s(:send, + s(:const, s(:const, s(:const, nil, :RSpock), :Helpers), :BlockCapture), + :capture, + @receiver_node, + s(:sym, @message) + ) + ) + + setup = s(:begin, expects_node, capture_call) + + assertion = s(:send, nil, :assert_same, + block_var, + s(:send, s(:lvar, capture_var), :call) + ) + + Interaction.new(setup, assertion) end def chain_call(receiver_node, method_name, *arg_nodes) @@ -141,6 +174,13 @@ def parse_lhs(node) end def parse_rhs(node) + @block_pass_node = nil + + 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 @@ -150,6 +190,13 @@ def parse_rhs(node) if @receiver_node.nil? raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must have a receiver." end + + # Extract &block from args if present + block_pass = @arg_nodes.find { |n| n.type == :block_pass } + if block_pass + @block_pass_node = block_pass + @arg_nodes = @arg_nodes.reject { |n| n.equal?(block_pass) } + end end def range(node) 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/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..fed96db --- /dev/null +++ b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb @@ -0,0 +1,170 @@ +# 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 ">> 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 "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 index aa6f704..8fba95d 100644 --- a/test/rspock/ast/interaction_transformation_test.rb +++ b/test/rspock/ast/interaction_transformation_test.rb @@ -348,11 +348,98 @@ def setup assert_match /tmp:1:5/, e.message end + # --- &block verification --- + + test "&block produces setup with expects + capture and assertion with assert_same" do + source = '1 * receiver.message("arg", &my_proc)' + ast = ASTTransform::Transformer.new.build_ast(source) + result = @transformation.run(ast) + + setup_source = Unparser.unparse(result.setup) + assert_match(/receiver\.expects\(:message\)\.with\("arg"\)\.times\(1\)/, setup_source) + assert_match(/RSpock::Helpers::BlockCapture\.capture\(receiver, :message\)/, setup_source) + + assertion_source = Unparser.unparse(result.assertion) + assert_match(/assert_same\(my_proc/, assertion_source) + assert_match(/__rspock_blk_0\.call/, assertion_source) + end + + test "&block without other args produces setup and assertion" do + source = '1 * receiver.message(&my_proc)' + ast = ASTTransform::Transformer.new.build_ast(source) + result = @transformation.run(ast) + + setup_source = Unparser.unparse(result.setup) + refute_match(/\.with\(/, setup_source) + assert_match(/receiver\.expects\(:message\)\.times\(1\)/, setup_source) + assert_match(/BlockCapture\.capture/, setup_source) + + refute_nil result.assertion + end + + test "&block with >> produces setup with returns and assertion" do + source = '1 * receiver.message(&my_proc) >> "result"' + ast = ASTTransform::Transformer.new.build_ast(source) + result = @transformation.run(ast) + + setup_source = Unparser.unparse(result.setup) + assert_match(/\.returns\("result"\)/, setup_source) + assert_match(/BlockCapture\.capture/, setup_source) + + refute_nil result.assertion + end + + test "interaction without &block has nil assertion" do + source = '1 * receiver.message("arg")' + ast = ASTTransform::Transformer.new.build_ast(source) + result = @transformation.run(ast) + + assert_nil result.assertion + assert_equal Unparser.unparse(result.setup), 'receiver.expects(:message).with("arg").times(1)' + end + + test "inline do...end block raises InteractionError" do + source = '1 * receiver.message("arg") do; end' + ast = ASTTransform::Transformer.new.build_ast(source) + + e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do + @transformation.run(ast) + end + + assert_match(/Inline blocks/, e.message) + assert_match(/&var/, e.message) + end + + test "inline { } block raises InteractionError" do + source = '1 * receiver.message("arg") { }' + ast = ASTTransform::Transformer.new.build_ast(source) + + e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do + @transformation.run(ast) + end + + assert_match(/Inline blocks/, e.message) + end + + test "unique index produces unique capture variable names" do + source = '1 * receiver.message(&my_proc)' + ast = ASTTransform::Transformer.new.build_ast(source) + + result0 = RSpock::AST::InteractionTransformation.new(0).run(ast) + result1 = RSpock::AST::InteractionTransformation.new(1).run(ast) + + assert_match(/__rspock_blk_0/, Unparser.unparse(result0.setup)) + assert_match(/__rspock_blk_1/, Unparser.unparse(result1.setup)) + assert_match(/__rspock_blk_0/, Unparser.unparse(result0.assertion)) + assert_match(/__rspock_blk_1/, Unparser.unparse(result1.assertion)) + end + private - def transform(source, *transformations) - transformations << @transformation if transformations.empty? - super(source, *transformations) + def transform(source) + ast = ASTTransform::Transformer.new.build_ast(source) + result = @transformation.run(ast) + Unparser.unparse(result.setup) 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 From d092f0a845821abe5c31982bb3b4557b61f532be Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 27 Feb 2026 11:55:57 -0500 Subject: [PATCH 4/4] refactor: introduce BodyNode and remove legacy interaction transformations Restructure TestNode to use positional children [DefNode, BodyNode, WhereNode?] for cleaner separation of test definition, body blocks, and parameterization. Delete legacy InteractionTransformation (which violated the AbstractTransformation contract by returning a non-Node Result struct) and InteractionBlockAssertionTransformation (redundant abstraction), porting their missing test coverage to the RSpock AST-based transformations. Made-with: Cursor --- ...eraction_block_assertion_transformation.rb | 50 -- lib/rspock/ast/interaction_transformation.rb | 212 --------- lib/rspock/ast/node.rb | 16 +- lib/rspock/ast/parser/test_method_parser.rb | 21 +- lib/rspock/ast/test_method_transformation.rb | 23 +- ...ction_to_mocha_mock_transformation_test.rb | 49 ++ .../ast/interaction_transformation_test.rb | 446 ------------------ .../ast/parser/interaction_parser_test.rb | 14 + 8 files changed, 93 insertions(+), 738 deletions(-) delete mode 100644 lib/rspock/ast/interaction_block_assertion_transformation.rb delete mode 100644 lib/rspock/ast/interaction_transformation.rb delete mode 100644 test/rspock/ast/interaction_transformation_test.rb diff --git a/lib/rspock/ast/interaction_block_assertion_transformation.rb b/lib/rspock/ast/interaction_block_assertion_transformation.rb deleted file mode 100644 index 89e1167..0000000 --- a/lib/rspock/ast/interaction_block_assertion_transformation.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/abstract_transformation' - -module RSpock - module AST - # Transforms an interaction block into an assertion that checks if the block - # is the same as the captured block passed to the interaction. - # - # @example - # 1 * receiver.message(&my_proc) - # => assert_same(my_proc, __rspock_blk_0.call) - # - # @see InteractionTransformation - class InteractionBlockAssertionTransformation < ASTTransform::AbstractTransformation - # @param index [Integer] the index of the block capture variable - def initialize(index = 0) - @index = index - end - - def run(node) - block_pass = extract_block_pass(node) - return nil unless block_pass - - capture_var = :"__rspock_blk_#{@index}" - block_var = block_pass.children[0] - - s(:send, nil, :assert_same, - block_var, - s(:send, s(:lvar, capture_var), :call) - ) - end - - private - - def extract_block_pass(node) - # Unwrap >> return value - if node.type == :send && node.children[1] == :>> - node = node.children[0] - end - - return nil unless node.type == :send && node.children[1] == :* - - rhs = node.children[2] - return nil unless rhs.type == :send - - rhs.children[2..].find { |n| n.type == :block_pass } - 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 711d402..0000000 --- a/lib/rspock/ast/interaction_transformation.rb +++ /dev/null @@ -1,212 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/abstract_transformation' - -module RSpock - module AST - class InteractionTransformation < ASTTransform::AbstractTransformation - class InteractionError < RuntimeError; end - - Interaction = Struct.new(:setup, :assertion) - - def initialize(index = 0) - @index = index - end - - def run(node) - return Interaction.new(node, nil) 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 - - if @block_pass_node - build_block_pass_result(result) - else - Interaction.new(result, nil) - end - end - - def build_block_pass_result(expects_node) - capture_var = :"__rspock_blk_#{@index}" - block_var = @block_pass_node.children[0] - - capture_call = s(:lvasgn, capture_var, - s(:send, - s(:const, s(:const, s(:const, nil, :RSpock), :Helpers), :BlockCapture), - :capture, - @receiver_node, - s(:sym, @message) - ) - ) - - setup = s(:begin, expects_node, capture_call) - - assertion = s(:send, nil, :assert_same, - block_var, - s(:send, s(:lvar, capture_var), :call) - ) - - Interaction.new(setup, assertion) - 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) - @block_pass_node = nil - - 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_node, @message, *@arg_nodes = node.children - - if @receiver_node.nil? - raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must have a receiver." - end - - # Extract &block from args if present - block_pass = @arg_nodes.find { |n| n.type == :block_pass } - if block_pass - @block_pass_node = block_pass - @arg_nodes = @arg_nodes.reject { |n| n.equal?(block_pass) } - 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 index 13ee4a6..68bc060 100644 --- a/lib/rspock/ast/node.rb +++ b/lib/rspock/ast/node.rb @@ -19,17 +19,13 @@ def self.build(type, *children) class TestNode < Node register :rspock_test - def def_node - children.find { |n| n.type == :rspock_def } - end - - def where_node - children.find { |n| n.type == :rspock_where } - end + def def_node = children[0] + def body_node = children[1] + def where_node = children[2] + end - def blocks - children.reject { |n| n.type == :rspock_def } - end + class BodyNode < Node + register :rspock_body end class DefNode < Node diff --git a/lib/rspock/ast/parser/test_method_parser.rb b/lib/rspock/ast/parser/test_method_parser.rb index a4a57b4..67047a0 100644 --- a/lib/rspock/ast/parser/test_method_parser.rb +++ b/lib/rspock/ast/parser/test_method_parser.rb @@ -9,10 +9,8 @@ module Parser # 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_given, ...), - # s(:rspock_when, ...), - # s(:rspock_then, ...), - # s(:rspock_where, s(:rspock_where_header, ...), ...)) + # s(:rspock_body, s(:rspock_given, ...), s(:rspock_when, ...), ...), + # s(:rspock_where, ...)) # optional class TestMethodParser include RSpock::AST::NodeBuilder @@ -87,10 +85,17 @@ def test_method_nodes(node) end def build_rspock_ast(node, blocks) - rspock_children = [] - rspock_children << s(:rspock_def, node.children[0], node.children[1]) - blocks.each { |block| rspock_children << block.to_rspock_node } - s(:rspock_test, *rspock_children) + 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 diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index eb5ba3b..660780c 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -28,12 +28,11 @@ def run(node) 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.blocks.map do |block_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) @@ -44,8 +43,8 @@ def transform(rspock_ast) end end - transformed_ast = rspock_ast.updated(nil, [rspock_ast.def_node, *transformed_blocks]) - build_ruby_ast(method_call, method_args, transformed_ast, where, hoisted_setups) + transformed_body = rspock_ast.body_node.updated(nil, transformed_blocks) + build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups) end def transform_then_block(then_node, hoisted_setups) @@ -84,12 +83,12 @@ def transform_expect_block(expect_node) # --- Build final Ruby AST --- - def build_ruby_ast(method_call, method_args, transformed_ast, where, hoisted_setups) + 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(transformed_ast, hoisted_setups) + build_test_body(body_node, hoisted_setups) ) test_def = HeaderNodesTransformation.new(where.header).run(test_def) @@ -102,15 +101,15 @@ def build_ruby_ast(method_call, method_args, transformed_ast, where, hoisted_set s(:block, method_call, method_args, - build_test_body(transformed_ast, hoisted_setups) + build_test_body(body_node, hoisted_setups) ) end end - def build_test_body(transformed_ast, hoisted_setups) + def build_test_body(body_node, hoisted_setups) body_children = [] - transformed_ast.children.each do |block_node| + body_node.children.each do |block_node| case block_node.type when :rspock_given body_children.concat(block_node.children) @@ -119,14 +118,14 @@ def build_test_body(transformed_ast, hoisted_setups) body_children.concat(block_node.children) when :rspock_then, :rspock_expect body_children.concat(block_node.children) - when :rspock_cleanup, :rspock_where, :rspock_def - # handled separately + when :rspock_cleanup + # handled below as ensure end end ast = s(:begin, *body_children) - cleanup = transformed_ast.children.find { |n| n.type == :rspock_cleanup } + 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)) diff --git a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb index fed96db..a7af222 100644 --- a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb +++ b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb @@ -86,6 +86,34 @@ def setup ) 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"', @@ -100,6 +128,27 @@ def setup ) 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) diff --git a/test/rspock/ast/interaction_transformation_test.rb b/test/rspock/ast/interaction_transformation_test.rb deleted file mode 100644 index 8fba95d..0000000 --- a/test/rspock/ast/interaction_transformation_test.rb +++ /dev/null @@ -1,446 +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 - - # --- &block verification --- - - test "&block produces setup with expects + capture and assertion with assert_same" do - source = '1 * receiver.message("arg", &my_proc)' - ast = ASTTransform::Transformer.new.build_ast(source) - result = @transformation.run(ast) - - setup_source = Unparser.unparse(result.setup) - assert_match(/receiver\.expects\(:message\)\.with\("arg"\)\.times\(1\)/, setup_source) - assert_match(/RSpock::Helpers::BlockCapture\.capture\(receiver, :message\)/, setup_source) - - assertion_source = Unparser.unparse(result.assertion) - assert_match(/assert_same\(my_proc/, assertion_source) - assert_match(/__rspock_blk_0\.call/, assertion_source) - end - - test "&block without other args produces setup and assertion" do - source = '1 * receiver.message(&my_proc)' - ast = ASTTransform::Transformer.new.build_ast(source) - result = @transformation.run(ast) - - setup_source = Unparser.unparse(result.setup) - refute_match(/\.with\(/, setup_source) - assert_match(/receiver\.expects\(:message\)\.times\(1\)/, setup_source) - assert_match(/BlockCapture\.capture/, setup_source) - - refute_nil result.assertion - end - - test "&block with >> produces setup with returns and assertion" do - source = '1 * receiver.message(&my_proc) >> "result"' - ast = ASTTransform::Transformer.new.build_ast(source) - result = @transformation.run(ast) - - setup_source = Unparser.unparse(result.setup) - assert_match(/\.returns\("result"\)/, setup_source) - assert_match(/BlockCapture\.capture/, setup_source) - - refute_nil result.assertion - end - - test "interaction without &block has nil assertion" do - source = '1 * receiver.message("arg")' - ast = ASTTransform::Transformer.new.build_ast(source) - result = @transformation.run(ast) - - assert_nil result.assertion - assert_equal Unparser.unparse(result.setup), 'receiver.expects(:message).with("arg").times(1)' - end - - test "inline do...end block raises InteractionError" do - source = '1 * receiver.message("arg") do; end' - ast = ASTTransform::Transformer.new.build_ast(source) - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - @transformation.run(ast) - end - - assert_match(/Inline blocks/, e.message) - assert_match(/&var/, e.message) - end - - test "inline { } block raises InteractionError" do - source = '1 * receiver.message("arg") { }' - ast = ASTTransform::Transformer.new.build_ast(source) - - e = assert_raises RSpock::AST::InteractionTransformation::InteractionError do - @transformation.run(ast) - end - - assert_match(/Inline blocks/, e.message) - end - - test "unique index produces unique capture variable names" do - source = '1 * receiver.message(&my_proc)' - ast = ASTTransform::Transformer.new.build_ast(source) - - result0 = RSpock::AST::InteractionTransformation.new(0).run(ast) - result1 = RSpock::AST::InteractionTransformation.new(1).run(ast) - - assert_match(/__rspock_blk_0/, Unparser.unparse(result0.setup)) - assert_match(/__rspock_blk_1/, Unparser.unparse(result1.setup)) - assert_match(/__rspock_blk_0/, Unparser.unparse(result0.assertion)) - assert_match(/__rspock_blk_1/, Unparser.unparse(result1.assertion)) - end - - private - - def transform(source) - ast = ASTTransform::Transformer.new.build_ast(source) - result = @transformation.run(ast) - Unparser.unparse(result.setup) - end - end - end -end diff --git a/test/rspock/ast/parser/interaction_parser_test.rb b/test/rspock/ast/parser/interaction_parser_test.rb index 226a66f..ac233d6 100644 --- a/test/rspock/ast/parser/interaction_parser_test.rb +++ b/test/rspock/ast/parser/interaction_parser_test.rb @@ -189,6 +189,20 @@ def setup 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)