Fixrb

Fix guide, version 1

Patterns

A specification apart from any implementation, put to work three ways.

A specification of Fix names no class: it says what a subject does, and the test picks the subject. These three patterns follow from that.

One specification, several implementations

When two classes do the same job, one specification describes the job, and each implementation is tested against it. The specification goes in a file of its own, fix/specs/stack.rb, which fix does not run, since its name does not end with _fix.rb:

require "fix"

Fix :Stack do
  on :empty? do
    it MUST be_true
  end

  on :push, 1 do
    on :push, 2 do
      on :pop do
        it MUST eq 2
      end
    end
  end

  on :pop do
    it MUST be_nil
  end
end

Each implementation has a short file that requires it and tests a subject. An Array, in fix/array_fix.rb:

require_relative "specs/stack"

exit(Fix[:Stack].test { [] })

A linked list of the project, in lib/linked_stack.rb:

class LinkedStack
  Node = Data.define(:value, :below)

  def initialize = @top = nil
  def empty? = @top.nil?
  def push(value) = tap { @top = Node.new(value, @top) }

  def pop
    value = @top&.value
    @top = @top&.below
    value
  end
end

and its file, fix/linked_stack_fix.rb:

require_relative "specs/stack"
require_relative "../lib/linked_stack"

exit(Fix[:Stack].test { LinkedStack.new })
fix
fix/specs/stack.rb:5: Success: expected true to be true
fix/specs/stack.rb:11: Success: expected 2 to eq 2
fix/specs/stack.rb:17: Success: expected nil to be nil
fix/specs/stack.rb:5: Success: expected true to be true
fix/specs/stack.rb:11: Success: expected 2 to eq 2
fix/specs/stack.rb:17: Success: expected nil to be nil
2 files met

The lines point at the specification, the same three twice; the count names the file of an implementation that is not met. Another implementation is one more short file.

The contract of a dependency

A project relies on what its dependencies do: that JSON.parse gives string keys unless asked otherwise, say. A specification of the dependency writes that down, and tells at the next upgrade whether it still holds:

require "fix"
require "json"

Fix :JSON do
  on :generate, { "id" => 1 } do
    it MUST eq '{"id":1}'
  end

  on :parse, '{"id":1}' do
    it MUST eq({ "id" => 1 })
  end

  on :parse, '{"id":1}', symbolize_names: true do
    it MUST eq({ id: 1 })
  end
end

exit(Fix[:JSON].test { JSON })
ruby json_fix.rb
json_fix.rb:6: Success: expected "{\"id\":1}" to eq "{\"id\":1}"
json_fix.rb:10: Success: expected {"id" => 1} to eq {"id" => 1}
json_fix.rb:14: Success: expected {id: 1} to eq {id: 1}

The subject is the module itself, and each on a call the project makes. Only what the project uses is written, not the whole library.

A contract in three levels

An interface often has methods that are required, others that are recommended, others that are optional: the three levels say so. Here, a plugin MUST transform a text, SHOULD have a name in lowercase, and MAY have a version:

require "fix"

Fix :Plugin do
  on :call, "text" do
    it MUST be_an_instance_of String
  end

  on :name do
    it SHOULD match(/\A[a-z]+\z/)
  end

  on :version do
    it MAY match(/\A\d+\.\d+\z/)
  end
end

class Upcase
  def call(text) = text.upcase
  def name = "Upcase"
end

class Reverse
  def call(text) = text.reverse
  def name = "reverse"
  def version = "1.2"
end

results = [
  Fix[:Plugin].test { Upcase.new },
  Fix[:Plugin].test { Reverse.new }
]

exit(results.all?)
ruby plugin_fix.rb
plugin_fix.rb:5: Success: expected "TEXT" to be an instance of String
plugin_fix.rb:9: Warning: expected "Upcase" to match /\A[a-z]+\z/
plugin_fix.rb:13: Info: NoMethodError: undefined method 'version' for an instance of Upcase
plugin_fix.rb:5: Success: expected "txet" to be an instance of String
plugin_fix.rb:9: Success: expected "reverse" to match /\A[a-z]+\z/
plugin_fix.rb:13: Success: expected "1.2" to match /\A\d+\.\d+\z/

The first three lines are those of Upcase: its name is not in lowercase, a warning, and it has no version, an info. Both pass, so both plugins meet the contract, and the status is 0. A plugin without call would get an error, and not pass.