Fixrb

spectus

Expectations with the requirement levels of RFC 2119.

An expectation says what a value MUST do, SHOULD do, or MAY do, in the words of RFC 2119. It is a level, a negation and a matcher; called with a block, it gives a result that says how it went, and never raises for an expectation that is not met. Spectus is the engine of Fix 1.0, and works with any matcher that has match? and to_s, such as those of matchi.

Version
6.0.0
Ruby
3.3 or later
Dependencies
defi
License
MIT

Install

Add gem "spectus" to your Gemfile, or run gem install spectus. The examples below use gem "matchi" for the matchers.

Usage

Spectus.must, must_not, should, should_not and may build a requirement from a matcher; may has no negative form, the RFC having none. call runs the matcher with the block and returns a result:

require "spectus"
require "matchi"

eq = Matchi::Eq.new(42)
requirement = Spectus.must(eq)
requirement.level  # => :MUST
requirement.negate # => false

result = requirement.call { 6 * 7 }
result.passed?     # => true
result.outcome     # => :success
result.actual      # => "42"
result.description # => "eq 42"
result.to_s        # => "Success: expected 42 to eq 42"

Spectus.must_not(eq).call { 42 }.to_s # => "Failure: expected 42 not to eq 42"
Spectus.should(eq).call { 2 }.to_s    # => "Warning: expected 2 to eq 42"
Spectus.must(eq).call { 1 / 0 }.to_s  # => "Error: ZeroDivisionError: divided by 0"

Outcomes

Two questions tell the levels apart. Does the method exist? MUST and SHOULD require it, MAY does not. Does it do what is specified? MUST and MAY require it, SHOULD recommends it. Hence five outcomes, by what match? answered or raised; a success, a warning and an info pass:

LevelTrueFalseMissing methodOther exception
MUSTsuccessfailureerrorerror
SHOULDsuccesswarningerrorerror
MAYsuccessfailureinfoerror

A negation swaps the first two columns. A missing method is a NoMethodError raised by match? on the very value the block returned; match? must answer true or false, and any other answer is an error.

require "spectus"
require "matchi"

# A method the value does not have: an error for MUST and SHOULD, an info
# for MAY, where the method is optional.
blank = Matchi::Predicate.new(:be_blank)
Spectus.must(blank).call { "" }.outcome   # => :error
Spectus.should(blank).call { "" }.outcome # => :error
Spectus.may(blank).call { "" }.outcome    # => :info
Spectus.may(blank).call { "" }.passed?    # => true

# A match that is false: a failure, or a warning for SHOULD.
empty = Matchi::Predicate.new(:be_empty)
Spectus.must(empty).call { [1] }.outcome   # => :failure
Spectus.should(empty).call { [1] }.outcome # => :warning
Spectus.may(empty).call { [1] }.outcome    # => :failure

# An exception the block raises reaches the matcher, which may expect it.
raises = Matchi::RaiseException.new(ZeroDivisionError)
Spectus.must(raises).call { 1 / 0 }.outcome          # => :success
Spectus.must(Matchi::Eq.new(1)).call { 1 / 0 }.outcome     # => :error
Spectus.must(Matchi::Eq.new(1)).call { 1 / 0 }.error_class # => "ZeroDivisionError"

Results

A result is plain data, frozen: the level, the negation, the outcome, the description of the matcher, the actual value as its inspect wrote it, and the class, message and backtrace of an exception, as strings in UTF-8 read defensively. It can be marshalled, shared between ractors and printed without running any user code again, which is what a runner that forks needs:

require "spectus"
require "matchi"

# A result is plain data, frozen: it can be marshalled and shared.
result = Spectus.must(Matchi::Eq.new(42)).call { raise "boom" }
result.frozen?                              # => true
result.error_class                          # => "RuntimeError"
result.error_message                        # => "boom"
result.actual                               # => "#<RuntimeError: boom>"
result.summary                              # => "RuntimeError: boom"
Marshal.load(Marshal.dump(result)) == result # => true
Ractor.shareable?(result)                   # => true

# The texts are read defensively: nothing in a result runs user code again.
loud = Object.new
def loud.inspect = raise("no")
Spectus.must(Matchi::Eq.new(1)).call { loud }.actual # => "an object of Object"

The block and match? run under the policy of defi: the exceptions a method raises on its own end in the result, while an Interrupt, a signal, a NoMemoryError and the end of a Timeout go through.

Documentation