From 55900ebecd03f5ee4c48cd28c20b90733bd0d531 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 5 Sep 2020 15:01:22 -0600 Subject: [PATCH] Initial rework of example type structure --- src/spectator/example.cr | 97 ++++++--------------- src/spectator/example_base.cr | 85 +++++++++++++++++++ src/spectator/example_component.cr | 26 ------ src/spectator/example_group.cr | 131 ----------------------------- 4 files changed, 109 insertions(+), 230 deletions(-) create mode 100644 src/spectator/example_base.cr delete mode 100644 src/spectator/example_component.cr delete mode 100644 src/spectator/example_group.cr diff --git a/src/spectator/example.cr b/src/spectator/example.cr index cc955d1..33ac453 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -1,83 +1,34 @@ -require "./example_component" -require "./test_wrapper" +require "./context_delegate" +require "./example_base" +require "./example_group" +require "./result" +require "./source" module Spectator - # Base class for all types of examples. - # Concrete types must implement the `#run_impl` method. - abstract class Example < ExampleComponent - @finished = false - @description : String? = nil + # Standard example that runs a test case. + class Example < ExampleBase + # Indicates whether the example already ran. + getter? finished : Bool = false - protected setter description + # Retrieves the result of the last time the example ran. + getter! result : Result - # Indicates whether the example has already been run. - def finished? : Bool - @finished + # Creates the example. + # The *delegate* contains the test context and method that runs the test case. + # The *name* describes the purpose of the example. + # It can be a `Symbol` to describe a type. + # The *source* tracks where the example exists in source code. + # The example will be assigned to *group* if it is provided. + def initialize(@delegate : ContextDelegate, + name : String | Symbol? = nil, source : Source? = nil, group : ExampleGroup? = nil) + super(name, source, group) end - # Group that the example belongs to. - getter group : ExampleGroup - - # Retrieves the internal wrapped instance. - protected getter test_wrapper : TestWrapper - - # Source where the example originated from. - def source : Source - @test_wrapper.source - end - - def description : String | Symbol - @description || @test_wrapper.description - end - - def symbolic? : Bool - return false unless @test_wrapper.description? - - description = @test_wrapper.description - description.starts_with?('#') || description.starts_with?('.') - end - - abstract def run_impl - - # Runs the example code. - # A result is returned, which represents the outcome of the test. - # An example can be run only once. - # An exception is raised if an attempt is made to run it more than once. + # Executes the test case. + # Returns the result of the execution. + # The result will also be stored in `#result`. def run : Result - raise "Attempted to run example more than once (#{self})" if finished? - run_impl - ensure - @finished = true - end - - # Creates the base of the example. - # The group should be the example group the example belongs to. - def initialize(@group, @test_wrapper) - end - - # Indicates there is only one example to run. - def example_count : Int - 1 - end - - # Retrieve the current example. - def [](index : Int) : Example - self - end - - # String representation of the example. - # This consists of the groups the example is in and the description. - # The string can be given to end-users to identify the example. - def to_s(io) - @group.to_s(io) - io << ' ' unless symbolic? && @group.symbolic? - io << description - end - - # Creates the JSON representation of the example, - # which is just its name. - def to_json(json : ::JSON::Builder) - json.string(to_s) + raise NotImplementedError.new("#run") end end end diff --git a/src/spectator/example_base.cr b/src/spectator/example_base.cr new file mode 100644 index 0000000..ef2a23b --- /dev/null +++ b/src/spectator/example_base.cr @@ -0,0 +1,85 @@ +require "./example_group" +require "./result" +require "./source" + +module Spectator + # Common base type for all examples. + abstract class ExampleBase + # Location of the example in source code. + getter! source : Source + + # User-provided name or description of the test. + # 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, the name should be set + # to the description of the first matcher that runs in the example. + # + # If this value is a `Symbol`, the user specified a type for the name. + getter! name : String | Symbol + + # Group the example belongs to. + # Hooks are used from this group. + getter! group : ExampleGroup + + # Assigns the group the example belongs to. + # If the example already belongs to a group, + # it will be removed from the previous group before adding it to the new group. + def group=(group : ExampleGroup?) + if (previous = @group) + previous.remove_example(self) + end + group.add_example(self) if group + @group = group + end + + # Creates the base of the example. + # The *name* describes the purpose of the example. + # It can be a `Symbol` to describe a type. + # The *source* tracks where the example exists in source code. + # The example will be assigned to *group* if it is provided. + def initialize(@name : String | Symbol? = nil, @source : Source? = nil, group : ExampleGroup? = nil) + # Ensure group is linked. + self.group = group + end + + # Indicates whether the example already ran. + abstract def finished? : Bool + + # Retrieves the result of the last time the example ran. + # This will be nil if the example hasn't run, + # and should not be nil if it has. + abstract def result? : Result? + + # Retrieves the result of the last time the example ran. + # Raises an error if the example hasn't run. + def result : Result + result? || raise(NilAssertionError("Example has no result")) + end + + # Constructs the full name or description of the example. + # This prepends names of groups this example is part of. + def to_s(io) + name = @name + + # Prefix with group's full name if the example belongs to a group. + if (group = @group) + group.to_s(io) + + # Add padding between the group name and example name, + # only if the names appear to be symbolic. + if group.name.is_a?(Symbol) && name.is_a?(String) + io << ' ' unless name.starts_with?('#') || name.starts_with?('.') + end + end + + name.to_s(io) + end + + # Exposes information about the example useful for debugging. + def inspect(io) + raise NotImplementedError.new("#inspect") + end + end +end diff --git a/src/spectator/example_component.cr b/src/spectator/example_component.cr deleted file mode 100644 index 9260f95..0000000 --- a/src/spectator/example_component.cr +++ /dev/null @@ -1,26 +0,0 @@ -module Spectator - # Abstract base for all examples and collections of examples. - # This is used as the base node type for the composite design pattern. - abstract class ExampleComponent - # Text that describes the context or test. - abstract def description : Symbol | String - - def full_description - to_s - end - - abstract def source : Source - - # Indicates whether the example (or group) has been completely run. - abstract def finished? : Bool - - # The number of examples in this instance. - abstract def example_count : Int - - # Lookup the example with the specified index. - abstract def [](index : Int) : Example - - # Indicates that the component references a type or method. - abstract def symbolic? : Bool - end -end diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr deleted file mode 100644 index ae8a5a8..0000000 --- a/src/spectator/example_group.cr +++ /dev/null @@ -1,131 +0,0 @@ -require "./example_component" - -module Spectator - # Shared base class for groups of examples. - # - # Represents a collection of examples and other groups. - # Use the `#each` methods to iterate through each child. - # However, these methods do not recurse into sub-groups. - # If you need that functionality, see `ExampleIterator`. - # Additionally, the indexer method (`#[]`) will index into sub-groups. - # - # This class also stores hooks to be associated with all examples in the group. - # The hooks can be invoked by running the `#run_before_hooks` and `#run_after_hooks` methods. - abstract class ExampleGroup < ExampleComponent - include Enumerable(ExampleComponent) - include Iterable(ExampleComponent) - - @example_count = 0 - - # Retrieves the children in the group. - # This only returns the direct descends (non-recursive). - # The children must be set (with `#children=`) prior to calling this method. - getter! children : Array(ExampleComponent) - - # Sets the children of the group. - # This should be called only from a builder in the `DSL` namespace. - # The children can be set only once - - # attempting to set more than once will raise an error. - # All sub-groups' children should be set before setting this group's children. - def children=(children : Array(ExampleComponent)) - raise "Attempted to reset example group children" if @children - @children = children - # Recursively count the number of examples. - # This won't work if a sub-group hasn't had their children set (is still nil). - @example_count = children.sum(&.example_count) - end - - def double(id, sample_values) - @doubles[id].build(sample_values) - end - - getter context - - def initialize(@context : TestContext) - end - - # Yields each direct descendant. - def each - children.each do |child| - yield child - end - end - - # Returns an iterator for each direct descendant. - def each : Iterator(ExampleComponent) - children.each - end - - # Number of examples in this group and all sub-groups. - def example_count : Int - @example_count - end - - # Retrieves an example by its index. - # This recursively searches for an example. - # - # Positive and negative indices can be used. - # Any value out of range will raise an `IndexError`. - # - # Examples are indexed as if they are in a flattened tree. - # For instance: - # ``` - # examples = [0, 1, [2, 3, 4], [5, [6, 7], 8], 9, [10]].flatten - # ``` - # The arrays symbolize groups, - # and the numbers are the index of the example in that slot. - def [](index : Int) : Example - offset = check_bounds(index) - find_nested(offset) - end - - # Checks whether an index is within acceptable bounds. - # If the index is negative, - # it will be converted to its positive equivalent. - # If the index is out of bounds, an `IndexError` is raised. - # If the index is in bounds, - # the positive index is returned. - private def check_bounds(index) - if index < 0 - raise IndexError.new if index < -example_count - index + example_count - else - raise IndexError.new if index >= example_count - index - end - end - - # Finds the example with the specified index in the children. - # The *index* must be positive and within bounds (use `#check_bounds`). - private def find_nested(index) - offset = index - # Loop through each child - # until one is found to contain the index. - found = children.each do |child| - count = child.example_count - # Example groups consider their range to be [0, example_count). - # Each child is offset by the total example count of the previous children. - # The group exposes them in this way: - # 1. [0, example_count of group 1) - # 2. [example_count of group 1, example_count of group 2) - # 3. [example_count of group n, example_count of group n + 1) - # To iterate through children, the offset is tracked. - # Each iteration removes the previous child's count. - # This way the child receives the expected range. - break child if offset < count - offset -= count - end - # The remaining offset is passed along to the child. - # If it's an `Example`, it returns itself. - # Otherwise, the indexer repeats the process for the next child. - # It should be impossible to get nil here, - # provided the bounds check and example counts are correct. - found.not_nil![offset] - end - - # Checks whether all examples in the group have been run. - def finished? : Bool - children.all?(&.finished?) - end - end -end