Merge branch 'release/0.10' into reporting

This commit is contained in:
Michael Miller 2021-05-08 13:23:57 -06:00
commit e8848d6855
No known key found for this signature in database
GPG key ID: FB9F12F7C646A4AD
7 changed files with 211 additions and 154 deletions

View file

@ -65,7 +65,7 @@ module Spectator::DSL
# Inserts the correct representation of a example's name. # Inserts the correct representation of a example's name.
# If *what* is a string, then it is dropped in as-is. # If *what* is a string, then it is dropped in as-is.
# For anything else, it is stringified. # For anything else, it is stringified.
# This is intended to be used to convert a description from the spec DSL to `Spec::Node#name`. # This is intended to be used to convert a description from the spec DSL to `Node#name`.
private macro _spectator_example_name(what) private macro _spectator_example_name(what)
{% if what.is_a?(StringLiteral) || {% if what.is_a?(StringLiteral) ||
what.is_a?(StringInterpolation) || what.is_a?(StringInterpolation) ||

View file

@ -56,7 +56,7 @@ module Spectator::DSL
# If *what* appears to be a type name, it will be symbolized. # If *what* appears to be a type name, it will be symbolized.
# If it's a string, then it is dropped in as-is. # If it's a string, then it is dropped in as-is.
# For anything else, it is stringified. # For anything else, it is stringified.
# This is intended to be used to convert a description from the spec DSL to `Spec::Node#name`. # This is intended to be used to convert a description from the spec DSL to `Node#name`.
private macro _spectator_group_name(what) private macro _spectator_group_name(what)
{% if (what.is_a?(Generic) || {% if (what.is_a?(Generic) ||
what.is_a?(Path) || what.is_a?(Path) ||

View file

@ -2,17 +2,25 @@ require "./example_context_delegate"
require "./example_group" require "./example_group"
require "./harness" require "./harness"
require "./location" require "./location"
require "./node"
require "./pending_result" require "./pending_result"
require "./result" require "./result"
require "./spec/node"
require "./tags" require "./tags"
module Spectator module Spectator
# Standard example that runs a test case. # Standard example that runs a test case.
class Example < Spec::Node class Example < Node
# Currently running example. # Currently running example.
class_getter! current : Example class_getter! current : Example
# Group the node belongs to.
getter! group : ExampleGroup
# Assigns the node to the specified *group*.
# This is an internal method and should only be called from `ExampleGroup`.
# `ExampleGroup` manages the association of nodes to groups.
protected setter group : ExampleGroup?
# Indicates whether the example already ran. # Indicates whether the example already ran.
getter? finished : Bool = false getter? finished : Bool = false
@ -31,8 +39,11 @@ module Spectator
# Note: The tags will not be merged with the parent tags. # Note: The tags will not be merged with the parent tags.
def initialize(@context : Context, @entrypoint : self ->, def initialize(@context : Context, @entrypoint : self ->,
name : String? = nil, location : Location? = nil, name : String? = nil, location : Location? = nil,
group : ExampleGroup? = nil, tags = Tags.new) @group : ExampleGroup? = nil, tags = Tags.new)
super(name, location, group, tags) super(name, location, tags)
# Ensure group is linked.
group << self if group
end end
# Creates a dynamic example. # Creates a dynamic example.
@ -44,11 +55,15 @@ module Spectator
# The example will be assigned to *group* if it is provided. # The example will be assigned to *group* if it is provided.
# A set of *tags* can be used for filtering and modifying example behavior. # A set of *tags* can be used for filtering and modifying example behavior.
# Note: The tags will not be merged with the parent tags. # Note: The tags will not be merged with the parent tags.
def initialize(name : String? = nil, location : Location? = nil, group : ExampleGroup? = nil, def initialize(name : String? = nil, location : Location? = nil,
tags = Tags.new, &block : self ->) @group : ExampleGroup? = nil, tags = Tags.new, &block : self ->)
super(name, location, group, tags) super(name, location, tags)
@context = NullContext.new @context = NullContext.new
@entrypoint = block @entrypoint = block
# Ensure group is linked.
group << self if group
end end
# Executes the test case. # Executes the test case.
@ -68,13 +83,13 @@ module Spectator
begin begin
@result = Harness.run do @result = Harness.run do
group?.try(&.call_once_before_all) @group.try(&.call_once_before_all)
if (parent = group?) if (parent = @group)
parent.call_around_each(self) { run_internal } parent.call_around_each(self) { run_internal }
else else
run_internal run_internal
end end
if (parent = group?) if (parent = @group)
parent.call_once_after_all if parent.finished? parent.call_once_after_all if parent.finished?
end end
end end
@ -85,10 +100,10 @@ module Spectator
end end
private def run_internal private def run_internal
group?.try(&.call_before_each(self)) @group.try(&.call_before_each(self))
@entrypoint.call(self) @entrypoint.call(self)
@finished = true @finished = true
group?.try(&.call_after_each(self)) @group.try(&.call_after_each(self))
end end
# Executes code within the example's test context. # Executes code within the example's test context.
@ -124,26 +139,27 @@ module Spectator
# Constructs the full name or description of the example. # Constructs the full name or description of the example.
# This prepends names of groups this example is part of. # This prepends names of groups this example is part of.
def to_s(io) def to_s(io)
if name? name = @name
super
else # Prefix with group's full name if the node belongs to a group.
io << "<anonymous>" if (parent = @group)
parent.to_s(io)
# Add padding between the node names
# only if the names don't appear to be symbolic.
# Skip blank group names (like the root group).
io << ' ' unless !parent.name? || # ameba:disable Style/NegatedConditionsInUnless
(parent.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
end end
super
end end
# Exposes information about the example useful for debugging. # Exposes information about the example useful for debugging.
def inspect(io) def inspect(io)
# Full example name. super
io << '"' io << ' '
to_s(io)
io << '"'
# Add location if it's available.
if (location = self.location)
io << " @ "
io << location
end
io << result io << result
end end
@ -153,6 +169,11 @@ module Spectator
json.string(to_s) json.string(to_s)
end end
# Creates a procsy from this example and the provided block.
def procsy(&block : ->)
Procsy.new(self, &block)
end
# Wraps an example to behave like a `Proc`. # Wraps an example to behave like a `Proc`.
# This is typically used for an *around_each* hook. # This is typically used for an *around_each* hook.
# Invoking `#call` or `#run` will run the example. # Invoking `#call` or `#run` will run the example.

View file

@ -1,71 +1,101 @@
require "./events" require "./events"
require "./example_procsy_hook" require "./example_procsy_hook"
require "./spec/node" require "./node"
module Spectator module Spectator
# Collection of examples and sub-groups. # Collection of examples and sub-groups.
class ExampleGroup < Spec::Node class ExampleGroup < Node
include Enumerable(Spec::Node) include Enumerable(Node)
include Events include Events
include Iterable(Spec::Node) include Iterable(Node)
@nodes = [] of Spec::Node @nodes = [] of Node
group_event before_all do |hooks| # Parent group this group belongs to.
Log.trace { "Processing before_all hooks for #{self}" } getter! group : ExampleGroup
if (parent = group?) # Assigns this group to the specified *group*.
parent.call_once_before_all # This is an internal method and should only be called from `ExampleGroup`.
# `ExampleGroup` manages the association of nodes to groups.
protected setter group : ExampleGroup?
# Calls all hooks from the parent group if there is a parent.
# The *hook* is the method name of the group hook to invoke.
private macro call_parent_hooks(hook)
if (parent = @group)
parent.{{hook.id}}
end
end end
# Calls all hooks from the parent group if there is a parent.
# The *hook* is the method name of the example hook to invoke.
# The current *example* must be provided.
private macro call_parent_hooks(hook, example)
if (parent = @group)
parent.{{hook.id}}({{example}})
end
end
# Calls group hooks of the current group.
private def call_hooks(hooks)
hooks.each do |hook| hooks.each do |hook|
Log.trace { "Invoking hook #{hook}" } Log.trace { "Invoking hook #{hook}" }
hook.call hook.call
end end
end end
# Calls example hooks of the current group.
# Requires the current *example*.
private def call_hooks(hooks, example)
hooks.each do |hook|
Log.trace { "Invoking hook #{hook}" }
hook.call(example)
end
end
group_event before_all do |hooks|
Log.trace { "Processing before_all hooks for #{self}" }
call_parent_hooks(:call_once_before_all)
call_hooks(hooks)
end
group_event after_all do |hooks| group_event after_all do |hooks|
Log.trace { "Processing after_all hooks for #{self}" } Log.trace { "Processing after_all hooks for #{self}" }
hooks.each do |hook| call_hooks(hooks)
Log.trace { "Invoking hook #{hook}" } call_parent_hooks(:call_once_after_all)
hook.call
end
if (parent = group?)
parent.call_once_after_all
end
end end
example_event before_each do |hooks, example| example_event before_each do |hooks, example|
Log.trace { "Processing before_each hooks for #{self}" } Log.trace { "Processing before_each hooks for #{self}" }
if (parent = group?) call_parent_hooks(:call_before_each, example)
parent.call_before_each(example) call_hooks(hooks, example)
end
hooks.each do |hook|
Log.trace { "Invoking hook #{hook}" }
hook.call(example)
end
end end
example_event after_each do |hooks, example| example_event after_each do |hooks, example|
Log.trace { "Processing after_each hooks for #{self}" } Log.trace { "Processing after_each hooks for #{self}" }
hooks.each do |hook| call_hooks(hooks, example)
Log.trace { "Invoking hook #{hook}" } call_parent_hooks(:call_after_each, example)
hook.call(example)
end end
if (parent = group?) # Creates the example group.
parent.call_after_each(example) # The *name* describes the purpose of the group.
end # It can be a `Symbol` to describe a type.
# The *location* tracks where the group exists in source code.
# This group will be assigned to the parent *group* if it is provided.
# A set of *tags* can be used for filtering and modifying example behavior.
def initialize(@name : Label = nil, @location : Location? = nil,
@group : ExampleGroup? = nil, @tags : Tags = Tags.new)
# Ensure group is linked.
group << self if group
end end
# Removes the specified *node* from the group. # Removes the specified *node* from the group.
# The node will be unassigned from this group. # The node will be unassigned from this group.
def delete(node : Spec::Node) def delete(node : Node)
# Only remove from the group if it is associated with this group. # Only remove from the group if it is associated with this group.
return unless node.group == self return unless node.group == self
@ -88,11 +118,31 @@ module Spectator
@nodes.all?(&.finished?) @nodes.all?(&.finished?)
end end
# Constructs the full name or description of the example group.
# This prepends names of groups this group is part of.
def to_s(io)
name = @name
# Prefix with group's full name if the node belongs to a group.
if (parent = @group)
parent.to_s(io)
# Add padding between the node names
# only if the names don't appear to be symbolic.
# Skip blank group names (like the root group).
io << ' ' unless !parent.name? || # ameba:disable Style/NegatedConditionsInUnless
(parent.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
end
super
end
# Adds the specified *node* to the group. # Adds the specified *node* to the group.
# Assigns the node to this group. # Assigns the node to this group.
# If the node already belongs to a group, # If the node already belongs to a group,
# it will be removed from the previous group before adding it to this group. # it will be removed from the previous group before adding it to this group.
def <<(node : Spec::Node) def <<(node : Node)
# Remove from existing group if the node is part of one. # Remove from existing group if the node is part of one.
if (previous = node.group?) if (previous = node.group?)
previous.delete(node) previous.delete(node)
@ -113,26 +163,26 @@ module Spectator
# Defines a hook for the *around_each* event. # Defines a hook for the *around_each* event.
# The block of code given to this method is invoked when the event occurs. # The block of code given to this method is invoked when the event occurs.
# The current example is provided as a block argument. # The current example is provided as a block argument.
def around_each(&block : Example::Procsy ->) : Nil def around_each(&block) : Nil
hook = ExampleProcsyHook.new(label: "around_each", &block) hook = ExampleProcsyHook.new(label: "around_each", &block)
add_around_each_hook(hook) add_around_each_hook(hook)
end end
# Signals that the *around_each* event has occurred. # Signals that the *around_each* event has occurred.
# All hooks associated with the event will be called. # All hooks associated with the event will be called.
def call_around_each(example : Example, &block : -> _) : Nil def call_around_each(example, &block : -> _) : Nil
# Avoid overhead if there's no hooks. # Avoid overhead if there's no hooks.
return yield if @around_hooks.empty? return yield if @around_hooks.empty?
# Start with a procsy that wraps the original code. # Start with a procsy that wraps the original code.
procsy = Example::Procsy.new(example, &block) procsy = example.procsy(&block)
procsy = wrap_around_each(procsy) procsy = wrap_around_each(procsy)
procsy.call procsy.call
end end
# Wraps a procsy with the *around_each* hooks from this example group. # Wraps a procsy with the *around_each* hooks from this example group.
# The returned procsy will call each hook then *procsy*. # The returned procsy will call each hook then *procsy*.
protected def wrap_around_each(procsy : Example::Procsy) : Example::Procsy protected def wrap_around_each(procsy)
# Avoid overhead if there's no hooks. # Avoid overhead if there's no hooks.
return procsy if @around_hooks.empty? return procsy if @around_hooks.empty?

View file

@ -1,6 +1,6 @@
require "./example" require "./example"
require "./example_group" require "./example_group"
require "./spec/node" require "./node"
module Spectator module Spectator
# Iterates through all examples in a group and its nested groups. # Iterates through all examples in a group and its nested groups.
@ -9,12 +9,12 @@ module Spectator
# Stack that contains the iterators for each group. # Stack that contains the iterators for each group.
# A stack is used to track where in the tree this iterator is. # A stack is used to track where in the tree this iterator is.
@stack : Array(Iterator(Spec::Node)) @stack : Array(Iterator(Node))
# Creates a new iterator. # Creates a new iterator.
# The *group* is the example group to iterate through. # The *group* is the example group to iterate through.
def initialize(@group : ExampleGroup) def initialize(@group : ExampleGroup)
iter = @group.each.as(Iterator(Spec::Node)) iter = @group.each.as(Iterator(Node))
@stack = [iter] @stack = [iter]
end end
@ -39,7 +39,7 @@ module Spectator
# Restart the iterator at the beginning. # Restart the iterator at the beginning.
def rewind def rewind
# Same code as `#initialize`, but return self. # Same code as `#initialize`, but return self.
iter = @group.each.as(Iterator(Spec::Node)) iter = @group.each.as(Iterator(Node))
@stack = [iter] @stack = [iter]
self self
end end

69
src/spectator/node.cr Normal file
View file

@ -0,0 +1,69 @@
require "./label"
require "./location"
require "./tags"
module Spectator
# A single item in a test spec.
# This is commonly an `Example` or `ExampleGroup`,
# but can be anything that should be iterated over when running the spec.
abstract class Node
# Location of the node in source code.
getter! location : Location
# User-provided name or description of the node.
# This does not include the group name or descriptions.
# Use `#to_s` to get the full name.
#
# This value will be nil if no name was provided.
# In this case, and the node is a runnable example,
# the name should be set to the description
# of the first matcher that runs in the test case.
#
# If this value is a `Symbol`, the user specified a type for the name.
getter! name : Label
# Updates the name of the node.
protected def name=(@name : String)
end
# User-defined keywords used for filtering and behavior modification.
getter tags : Tags
# Creates the node.
# The *name* describes the purpose of the node.
# It can be a `Symbol` to describe a type.
# The *location* tracks where the node exists in source code.
# A set of *tags* can be used for filtering and modifying example behavior.
def initialize(@name : Label = nil, @location : Location? = nil, @tags : Tags = Tags.new)
end
# Indicates whether the node has completed.
abstract def finished? : Bool
# Checks if the node has been marked as pending.
# Pending items should be skipped during execution.
def pending?
tags.includes?(:pending)
end
# Constructs the full name or description of the node.
# This prepends names of groups this node is part of.
def to_s(io)
(@name || "<anonymous>").to_s(io)
end
# Exposes information about the node useful for debugging.
def inspect(io)
# Full node name.
io << '"'
to_s(io)
io << '"'
# Add location if it's available.
if (location = self.location)
io << " @ "
io << location
end
end
end
end

View file

@ -1,83 +0,0 @@
require "../label"
require "../location"
require "../tags"
module Spectator
class Spec
# A single item in a test spec.
# This is commonly an `Example` or `ExampleGroup`,
# but can be anything that should be iterated over when running the spec.
abstract class Node
# Location of the node in source code.
getter! location : Location
# User-provided name or description of the node.
# This does not include the group name or descriptions.
# Use `#to_s` to get the full name.
#
# This value will be nil if no name was provided.
# In this case, and the node is a runnable example,
# the name should be set to the description
# of the first matcher that runs in the test case.
#
# If this value is a `Symbol`, the user specified a type for the name.
getter! name : Label
# Updates the name of the node.
protected def name=(@name : String)
end
# Group the node belongs to.
getter! group : ExampleGroup
# User-defined keywords used for filtering and behavior modification.
getter tags : Tags
# Assigns the node to the specified *group*.
# This is an internal method and should only be called from `ExampleGroup`.
# `ExampleGroup` manages the association of nodes to groups.
protected setter group : ExampleGroup?
# Creates the node.
# The *name* describes the purpose of the node.
# It can be a `Symbol` to describe a type.
# The *location* tracks where the node exists in source code.
# The node will be assigned to *group* if it is provided.
# A set of *tags* can be used for filtering and modifying example behavior.
def initialize(@name : Label = nil, @location : Location? = nil,
group : ExampleGroup? = nil, @tags : Tags = Tags.new)
# Ensure group is linked.
group << self if group
end
# Indicates whether the node has completed.
abstract def finished? : Bool
# Checks if the node has been marked as pending.
# Pending items should be skipped during execution.
def pending?
tags.includes?(:pending)
end
# Constructs the full name or description of the node.
# This prepends names of groups this node is part of.
def to_s(io)
name = @name
# Prefix with group's full name if the node belongs to a group.
if (group = @group)
group.to_s(io)
# Add padding between the node names
# only if the names don't appear to be symbolic.
# Skip blank group names (like the root group).
io << ' ' unless !group.name? || # ameba:disable Style/NegatedConditionsInUnless
(group.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
end
name.to_s(io)
end
end
end
end