Skip to content

Commit

Permalink
Spec: refactor to make it easier to add more filter kinds
Browse files Browse the repository at this point in the history
Before this commit an `it` block ran immediately. This makes it hard to
implement other filtering mechanisms. In particular to implement
something like RSpec's `focus` we need to first know whether there's any
example marked with `focus: true`. Without collecting all examples
first we can't do this.

So, this commit first collects all describes, contexts and examples and
then filters then. The code is now much cleaner is easier to extend.
  • Loading branch information
asterite committed Aug 29, 2019
1 parent 65ce309 commit 4235af5
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 142 deletions.
137 changes: 48 additions & 89 deletions src/spec/context.cr
@@ -1,6 +1,12 @@
require "./item"

module Spec
# :nodoc:
#
# A context represents a `describe` or `context`.
abstract class Context
# All the children, which can be `describe`/`context` or `it`
getter children = [] of NestedContext | Example
end

# :nodoc:
Expand All @@ -12,8 +18,17 @@ module Spec
elapsed : Time::Span?,
exception : Exception?

def self.root_context
RootContext.instance
end

# :nodoc:
#
# The root context is the main interface that the spec DSL interacts with.
class RootContext < Context
class_getter instance = RootContext.new
@@current_context : Context = @@instance

def initialize
@results = {
success: [] of Result,
Expand All @@ -23,35 +38,20 @@ module Spec
}
end

def parent
nil
def run
children.each &.run
end

def succeeded
@results[:fail].empty? && @results[:error].empty?
end

def self.report(kind, full_description, file, line, elapsed = nil, ex = nil)
def report(kind, full_description, file, line, elapsed = nil, ex = nil)
result = Result.new(kind, full_description, file, line, elapsed, ex)
@@contexts_stack.last.report(result)
end

def report(result)
Spec.formatters.each(&.report(result))

@results[result.kind] << result
end

def self.print_results(elapsed_time, aborted = false)
@@instance.print_results(elapsed_time, aborted)
end

def self.succeeded
@@instance.succeeded
end

def self.finish(elapsed_time, aborted = false)
@@instance.finish(elapsed_time, aborted)
def succeeded
@results[:fail].empty? && @results[:error].empty?
end

def finish(elapsed_time, aborted = false)
Expand Down Expand Up @@ -147,69 +147,36 @@ module Spec
end
end

@@instance = RootContext.new
@@contexts_stack = [@@instance] of Context
def describe(description, file, line, end_line, &block)
context = Spec::NestedContext.new(@@current_context, description, file, line, end_line)
@@current_context.children << context

def self.describe(description, file, line, &block)
describe = Spec::NestedContext.new(description, file, line, @@contexts_stack.last)
@@contexts_stack.push describe
Spec.formatters.each(&.push(describe))
block.call
Spec.formatters.each(&.pop)
@@contexts_stack.pop
end

def self.it(description, file, line, end_line, &block)
Spec::RootContext.check_nesting_spec(file, line) do
return unless Spec.split_filter_matches
return unless Spec.matches?(description, file, line, end_line)

Spec.formatters.each(&.before_example(description))

start = Time.monotonic
begin
Spec.run_before_each_hooks
block.call
Spec::RootContext.report(:success, description, file, line, Time.monotonic - start)
rescue ex : Spec::AssertionFailed
Spec::RootContext.report(:fail, description, file, line, Time.monotonic - start, ex)
Spec.abort! if Spec.fail_fast?
rescue ex
Spec::RootContext.report(:error, description, file, line, Time.monotonic - start, ex)
Spec.abort! if Spec.fail_fast?
ensure
Spec.run_after_each_hooks

# We do this to give a chance for signals (like CTRL+C) to be handled,
# which currently are only handled when there's a fiber switch
# (IO stuff, sleep, etc.). Without it the user might wait more than needed
# after pressing CTRL+C to quit the tests.
Fiber.yield
end
old_context = @@current_context
@@current_context = context
begin
block.call
ensure
@@current_context = old_context
end
end

def self.pending(description, file, line, end_line, &block)
Spec::RootContext.check_nesting_spec(file, line) do
return unless Spec.matches?(description, file, line, end_line)

Spec.formatters.each(&.before_example(description))

Spec::RootContext.report(:pending, description, file, line)
end
def it(description, file, line, end_line, &block)
add_example(description, file, line, end_line, block, pending: false)
end

def self.matches?(description, pattern, line, locations)
@@contexts_stack.any?(&.matches?(pattern, line, locations)) || description =~ pattern
def pending(description, file, line, end_line, &block)
add_example(description, file, line, end_line, block, pending: true)
end

def matches?(pattern, line, locations)
false
private def add_example(description, file, line, end_line, block, pending)
check_nesting_spec(file, line) do
@@current_context.children << Example.new(@@current_context, description, file, line, end_line, block, pending)
end
end

@@spec_nesting = false

def self.check_nesting_spec(file, line, &block)
def check_nesting_spec(file, line, &block)
raise NestingSpecError.new("can't nest `it` or `pending`", file, line) if @@spec_nesting

@@spec_nesting = true
Expand All @@ -223,29 +190,21 @@ module Spec

# :nodoc:
class NestedContext < Context
getter parent : Context
getter description : String
getter file : String
getter line : Int32
include Item

def initialize(@description : String, @file, @line, @parent)
end
getter! parent : Context

def report(result)
@parent.report Result.new(result.kind, "#{@description} #{result.description}", result.file, result.line, result.elapsed, result.exception)
def initialize(@parent : Context, @description : String, @file : String, @line : Int32, @end_line : Int32)
end

def matches?(pattern, line, locations)
return true if @description =~ pattern
return true if @line == line

if locations
lines = locations[@file]?
return true unless lines
return lines.includes?(@line)
end
def run
Spec.formatters.each(&.push(self))
children.each &.run
Spec.formatters.each(&.pop)
end

false
def report(result)
parent.report Result.new(result.kind, "#{@description} #{result.description}", result.file, result.line, result.elapsed, result.exception)
end
end
end
74 changes: 29 additions & 45 deletions src/spec/dsl.cr
Expand Up @@ -109,58 +109,19 @@ module Spec
lines << line
end

@@split_filter : NamedTuple(remainder: Int32, quotient: Int32)? = nil
record SplitFilter, remainder : Int32, quotient : Int32

@@split_filter : SplitFilter? = nil

def self.add_split_filter(filter)
if filter
r, m = filter.split('%').map &.to_i
@@split_filter = {remainder: r, quotient: m}
@@split_filter = SplitFilter.new(remainder: r, quotient: m)
else
@@split_filter = nil
end
end

@@spec_counter = -1

def self.split_filter_matches
split_filter = @@split_filter

if split_filter
@@spec_counter += 1
@@spec_counter % split_filter[:quotient] == split_filter[:remainder]
else
true
end
end

# :nodoc:
def self.matches?(description, file, line, end_line = line)
spec_pattern = @@pattern
spec_line = @@line
locations = @@locations

# When a method invokes `it` and only forwards line information,
# not end_line information (this can happen in code before we
# introduced the end_line feature) then running a spec by giving
# a line won't work because end_line might be located before line.
# So, we also check `line == spec_line` to somehow preserve
# backwards compatibility.
if spec_line && (line == spec_line || line <= spec_line <= end_line)
return true
end

if locations
lines = locations[file]?
return true if lines && lines.any? { |l| line == l || line <= l <= end_line }
end

if spec_pattern || spec_line || locations
Spec::RootContext.matches?(description, spec_pattern, spec_line, locations)
else
true
end
end

@@fail_fast = false

# :nodoc:
Expand Down Expand Up @@ -199,10 +160,33 @@ module Spec
# :nodoc:
def self.run
start_time = Time.monotonic

at_exit do
run_filters
root_context.run
ensure
elapsed_time = Time.monotonic - start_time
Spec::RootContext.finish(elapsed_time, @@aborted)
exit 1 unless Spec::RootContext.succeeded && !@@aborted
root_context.finish(elapsed_time, @@aborted)
exit 1 unless root_context.succeeded && !@@aborted
end
end

# :nodoc:
def self.run_filters
if pattern = @@pattern
root_context.filter_by_pattern(pattern)
end

if line = @@line
root_context.filter_by_line(line)
end

if locations = @@locations
root_context.filter_by_locations(locations)
end

if split_filter = @@split_filter
root_context.filter_by_split(split_filter)
end
end
end
Expand Down
48 changes: 48 additions & 0 deletions src/spec/example.cr
@@ -0,0 +1,48 @@
require "./item"

module Spec
class Example
include Item

getter parent : Context
getter block : ->
getter? pending : Bool

def initialize(@parent : Context, @description : String,
@file : String, @line : Int32, @end_line : Int32,
@block : ->, @pending : Bool)
end

def run
Spec.root_context.check_nesting_spec(file, line) do
Spec.formatters.each(&.before_example(description))

if pending?
Spec.root_context.report(:pending, description, file, line)
return
end

start = Time.monotonic
begin
Spec.run_before_each_hooks
block.call
Spec.root_context.report(:success, description, file, line, Time.monotonic - start)
rescue ex : Spec::AssertionFailed
Spec.root_context.report(:fail, description, file, line, Time.monotonic - start, ex)
Spec.abort! if Spec.fail_fast?
rescue ex
Spec.root_context.report(:error, description, file, line, Time.monotonic - start, ex)
Spec.abort! if Spec.fail_fast?
ensure
Spec.run_after_each_hooks

# We do this to give a chance for signals (like CTRL+C) to be handled,
# which currently are only handled when there's a fiber switch
# (IO stuff, sleep, etc.). Without it the user might wait more than needed
# after pressing CTRL+C to quit the tests.
Fiber.yield
end
end
end
end
end

0 comments on commit 4235af5

Please sign in to comment.