sus-0.35.2/0000755000004100000410000000000015152126155012446 5ustar www-datawww-datasus-0.35.2/bin/0000755000004100000410000000000015152126155013216 5ustar www-datawww-datasus-0.35.2/bin/sus0000755000004100000410000000075415152126155013764 0ustar www-datawww-data#!/usr/bin/env ruby # frozen_string_literal: true require_relative "../lib/sus/config" config = Sus::Config.load require_relative "../lib/sus" registry = config.registry if config.verbose? output = config.output verbose = true else output = Sus::Output::Null.new verbose = false end assertions = Sus::Assertions.default(output: output, verbose: verbose) config.before_tests(assertions) registry.call(assertions) config.after_tests(assertions) unless assertions.passed? exit(1) end sus-0.35.2/bin/sus-tree0000755000004100000410000000036715152126155014721 0ustar www-datawww-data#!/usr/bin/env ruby # frozen_string_literal: true require "json" require_relative "../lib/sus/config" config = Sus::Config.load require_relative "../lib/sus" verbose = false registry = config.registry puts Sus::Tree.new(registry.base).to_json sus-0.35.2/bin/sus-parallel0000755000004100000410000000253515152126155015555 0ustar www-datawww-data#!/usr/bin/env ruby # frozen_string_literal: true require_relative "../lib/sus/config" config = Sus::Config.load Result = Struct.new(:job, :assertions) require_relative "../lib/sus" require_relative "../lib/sus/output" jobs = Thread::Queue.new results = Thread::Queue.new guard = Thread::Mutex.new progress = Sus::Output::Progress.new(config.output) require "etc" count = Etc.nprocessors loader = Thread.new do registry = config.registry registry.each do |child| guard.synchronize{progress.expand} jobs << child end jobs.close end top = Sus::Assertions.new(output: Sus::Output::Null.new) config.before_tests(top) aggregation = Thread.new do while result = results.pop guard.synchronize{progress.increment} top.add(result.assertions) guard.synchronize{progress.report(count, top, :busy)} end guard.synchronize{progress.clear} top end workers = count.times.map do |index| Thread.new do while job = jobs.pop guard.synchronize{progress.report(index, job, :busy)} assertions = Sus::Assertions.new(output: Sus::Output::Null.new) job.call(assertions) results << Result.new(job, assertions) guard.synchronize{progress.report(index, "idle", :free)} end end end loader.join workers.each(&:join) results.close assertions = aggregation.value config.after_tests(assertions) unless assertions.passed? exit(1) end sus-0.35.2/bin/sus-host0000755000004100000410000000461315152126155014735 0ustar www-datawww-data#!/usr/bin/env ruby # frozen_string_literal: true require "json" require_relative "../lib/sus/config" config = Sus::Config.load require_relative "../lib/sus" verbose = false guard = Thread::Mutex.new require "etc" count = Etc.nprocessors $stdout.sync = true require_relative "../lib/sus/output/structured" input = $stdin.dup $stdin.reopen(File::NULL) output = $stdout.dup $stdout.reopen($stderr) def messages_for(assertions) messages = [] assertions.each_failure do |failure| messages << failure.message end return messages end while line = input.gets message = JSON.parse(line) if tests = message["run"] jobs = Thread::Queue.new results = Thread::Queue.new top = Sus::Assertions.new(measure: true) config.before_tests(top) aggregate = Thread.new do while result = results.pop top.add(result) end end loader = Thread.new do registry = config.load_registry(tests) registry.each do |child| jobs << child end jobs.close end workers = count.times.map do |index| Thread.new do while job = jobs.pop guard.synchronize do output.puts JSON.generate({started: job.identity}) end structured_output = Sus::Output::Structured.buffered(output, job.identity) assertions = Sus::Assertions.new(output: structured_output, measure: true) job.call(assertions) results.push(assertions) guard.synchronize do if assertions.passed? output.puts JSON.generate({passed: job.identity, messages: messages_for(assertions), duration: assertions.clock.ms}) elsif assertions.errored? output.puts JSON.generate({errored: job.identity, messages: messages_for(assertions), duration: assertions.clock.ms}) else output.puts JSON.generate({failed: job.identity, messages: messages_for(assertions), duration: assertions.clock.ms}) end end end end end loader.join workers.each(&:join) results.close aggregate.join config.after_tests(top) workers.each(&:join) if config.respond_to?(:covered) if covered = config.covered and covered.record? covered.policy.each do |coverage| output.puts JSON.generate({coverage: coverage.path, counts: coverage.counts}) end end end output.puts JSON.generate({finished: true, message: top.output.string, duration: top.clock.ms}) else $stderr.puts "Unknown message: #{message}" end end sus-0.35.2/lib/0000755000004100000410000000000015152126155013214 5ustar www-datawww-datasus-0.35.2/lib/sus/0000755000004100000410000000000015152126155014026 5ustar www-datawww-datasus-0.35.2/lib/sus/output.rb0000644000004100000410000000332015152126155015711 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "output/bar" require_relative "output/text" require_relative "output/xterm" require_relative "output/null" require_relative "output/progress" module Sus # Represents output handlers for test results and messages. module Output # Create an appropriate output handler for the given IO. # @parameter io [IO] The IO object to write to. # @returns [XTerm, Text] An XTerm handler if the IO is a TTY, otherwise a Text handler. def self.for(io) if io.isatty XTerm.new(io) else Text.new(io) end end # Create a default output handler with styling configured. # @parameter io [IO] The IO object to write to (defaults to $stderr). # @returns [XTerm, Text] A configured output handler. def self.default(io = $stderr) output = self.for(io) Output::Bar.register(output) output[:context] = output.style(nil, nil, :bold) output[:describe] = output.style(:cyan) output[:it] = output.style(:cyan) output[:with] = output.style(:cyan) output[:variable] = output.style(:blue, nil, :bold) output[:path] = output.style(:yellow) output[:line] = output.style(:yellow) output[:identity] = output.style(:yellow) output[:passed] = output.style(:green) output[:failed] = output.style(:red) output[:deferred] = output.style(:yellow) output[:skipped] = output.style(:blue) output[:errored] = output.style(:red) # output[:inform] = output.style(nil, nil, :bold) return output end # Create a buffered output handler. # @returns [Buffered] A new buffered output handler. def self.buffered Buffered.new end end end sus-0.35.2/lib/sus/expect.rb0000644000004100000410000000535015152126155015646 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus # Represents an expectation that can be used with predicates to make assertions. class Expect # Initialize a new Expect instance. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to make expectations about. # @parameter inverted [Boolean] Whether the expectation is inverted (not). # @parameter distinct [Boolean] Whether this expectation should be treated as distinct. def initialize(assertions, subject, inverted: false, distinct: false) @assertions = assertions @subject = subject # We capture this here, as changes to state may cause the inspect output to change, affecting the output produced by #print. @inspect = @subject.inspect @inverted = inverted @distinct = true end # @attribute [Object] The subject being tested. attr :subject # @attribute [Boolean] Whether the expectation is inverted. attr :inverted # Invert this expectation (expect not). # @returns [Expect] A new Expect instance with inverted expectation. def not self.dup.tap do |expect| expect.instance_variable_set(:@inverted, !@inverted) end end # Print a representation of this expectation. # @parameter output [Output] The output target. def print(output) output.write("expect ", :variable, @inspect, :reset, " ") if @inverted output.write("not to", :reset) else output.write("to", :reset) end end # Apply a predicate to this expectation. # @parameter predicate [Object] The predicate to apply. # @returns [Expect] Returns self for method chaining. def to(predicate) # This gets the identity scoped to the current call stack, which ensures that any failures are logged at this point in the code. identity = @assertions.identity&.scoped @assertions.nested(self, inverted: @inverted, identity: identity, distinct: @distinct) do |assertions| predicate.call(assertions, @subject) end return self end # Apply another predicate to this expectation (alias for {#to}). # @parameter predicate [Object] The predicate to apply. # @returns [Expect] Returns self for method chaining. def and(predicate) return to(predicate) end end class Base # Create an expectation about a subject or block. # @parameter subject [Object, nil] The subject to make expectations about. # @yields {...} Optional block to make expectations about. # @returns [Expect] A new Expect instance. def expect(subject = nil, &block) if block_given? Expect.new(@__assertions__, block, distinct: true) else Expect.new(@__assertions__, subject, distinct: true) end end end end sus-0.35.2/lib/sus/raise_exception.rb0000644000004100000410000000435215152126155017540 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus # Represents a predicate that checks if a block raises an exception. class RaiseException # Initialize a new RaiseException predicate. # @parameter exception_class [Class] The exception class to expect. # @parameter message [String, Regexp, Object | Nil] Optional message matcher. def initialize(exception_class = Exception, message: nil) @exception_class = exception_class @message = message @predicate = nil end # Add an additional predicate to check on the exception. # @parameter predicate [Object] The predicate to apply to the exception. # @returns [RaiseException] Returns self for method chaining. def and(predicate) @predicate = predicate return self end # Evaluate this predicate against a subject (block). # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Proc] The block to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| begin subject.call # Didn't throw any exception, so the expectation failed: assertions.assert(false, "raised") rescue @exception_class => exception # Did it have the right message? if @message Expect.new(assertions, exception.message).to(@message) else assertions.assert(true, "raised") end @predicate&.call(assertions, exception) end end end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("raise exception") if @exception_class output.write(" ", :variable, @exception_class, :reset) end if @message output.write(" with message ", :variable, @message, :reset) end if @predicate output.write(" and ", @predicate) end end end class Base # Create a predicate that checks if a block raises an exception. # @parameter exception_class [Class] The exception class to expect. # @parameter message [String, Regexp, Object | Nil] Optional message matcher. # @returns [RaiseException] A new RaiseException predicate. def raise_exception(...) RaiseException.new(...) end end end sus-0.35.2/lib/sus/have_duration.rb0000644000004100000410000000314415152126155017205 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus # Represents a predicate that measures the duration of a block execution. class HaveDuration # Initialize a new HaveDuration predicate. # @parameter predicate [Object] The predicate to apply to the measured duration. def initialize(predicate) @predicate = predicate end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("have duration ") @predicate.print(output) end # Evaluate this predicate against a subject (block). # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Proc] The block to measure. def call(assertions, subject) assertions.nested(self) do |assertions| Expect.new(assertions, measure(subject)).to(@predicate) end end private # Measure the duration of executing a block. # @parameter subject [Proc] The block to measure. # @returns [Float] The duration in seconds. def measure(subject) start_time = now subject.call return now - start_time end # Get the current monotonic time. # @returns [Float] The current time in seconds. def now ::Process.clock_gettime(Process::CLOCK_MONOTONIC) end end class Base # Create a predicate that measures the duration of a block execution. # @parameter predicate [Object] The predicate to apply to the measured duration. # @returns [HaveDuration] A new HaveDuration predicate. def have_duration(...) HaveDuration.new(...) end end end sus-0.35.2/lib/sus/config.rb0000644000004100000410000002054115152126155015622 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. require_relative "clock" require_relative "registry" module Sus # Represents the configuration for running tests. class Config # The default path to the configuration file. PATH = "config/sus.rb" # Find the configuration file path for the given root directory. # @parameter root [String] The root directory to search in. # @returns [String | Nil] The path to the configuration file if it exists. def self.path(root) path = ::File.join(root, PATH) if ::File.exist?(path) return path end end # Load configuration from the given root directory. # @parameter root [String] The root directory to load configuration from. # @parameter arguments [Array] Command line arguments to parse. # @returns [Config] A new Config instance. def self.load(root: Dir.pwd, arguments: ARGV) derived = Class.new(self) if path = self.path(root) config = Module.new config.module_eval(::File.read(path), path) derived.prepend(config) end options = { verbose: !!arguments.delete("--verbose") } return derived.new(root, arguments, **options) end # Initialize a new Config instance. # @parameter root [String] The root directory for the project. # @parameter paths [Array] Optional paths to specific test files. # @parameter verbose [Boolean] Whether to output verbose information. def initialize(root, paths, verbose: false) @root = root @paths = paths @verbose = verbose @clock = Clock.new self.add_default_load_paths end # Add a directory to the load path. # @parameter path [String] The path to add. def add_load_path(path) path = ::File.expand_path(path, @root) if ::File.directory?(path) $LOAD_PATH.unshift(path) end end # Add default load paths (lib and fixtures). def add_default_load_paths add_load_path("lib") add_load_path("fixtures") end # @attribute [String] The root directory for the project. attr :root # @attribute [Array] Optional paths to specific test files. attr :paths # @returns [Boolean] Whether verbose output is enabled. def verbose? @verbose end # @returns [Boolean] Whether only a partial set of tests is being run. def partial? @paths.any? end # @returns [Output] The output handler to use. def output @output ||= Sus::Output.default end # The default pattern for finding test files. DEFAULT_TEST_PATTERN = "test/**/*.rb" # @returns [Array(String)] Paths to all test files matching the default pattern. def test_paths return Dir.glob(DEFAULT_TEST_PATTERN, base: @root) end # Create a new registry instance. # @returns [Registry] A new Registry instance. def make_registry Sus::Registry.new(root: @root) end # Load the test registry, optionally filtering by paths. # @parameter paths [Array | Nil] Optional paths to filter tests by. # @returns [Registry, Filter] The loaded registry, possibly wrapped in a Filter. def load_registry(paths = @paths) registry = make_registry if paths&.any? registry = Sus::Filter.new(registry) paths.each do |path| registry.load(path) end else test_paths.each do |path| registry.load(path) end end return registry end # @returns [Registry] The test registry, loading it if necessary. def registry @registry ||= self.load_registry end # Prepare Ruby warnings for deprecated features. def prepare_warnings! Warning[:deprecated] = true end # Called before tests are run. # @parameter assertions [Assertions] The assertions instance. # @parameter output [Output] The output handler. def before_tests(assertions, output: self.output) @clock.reset! @clock.start! prepare_warnings! end # Called after tests are run. # @parameter assertions [Assertions] The assertions instance. # @parameter output [Output] The output handler. def after_tests(assertions, output: self.output) @clock.stop! self.print_summary(output, assertions) end protected # Print a summary of test results. # @parameter output [Output] The output handler. # @parameter assertions [Assertions] The assertions instance. def print_summary(output, assertions) assertions.print(output) output.puts print_finished_statistics(output, assertions) unless assertions.count.zero? if !partial? and assertions.passed? print_test_feedback(output, assertions) end print_slow_tests(output, assertions) end print_failed_assertions(output, assertions) end # Print finished statistics. # @parameter output [Output] The output handler. # @parameter assertions [Assertions] The assertions instance. def print_finished_statistics(output, assertions) duration = @clock.duration if assertions.count.zero? output.puts "🏴 Finished in ", @clock, "." else rate = assertions.count / duration output.puts "🏁 Finished in ", @clock, "; #{rate.round(3)} assertions per second." end end # Print feedback about the test suite. # @parameter output [Output] The output handler. # @parameter assertions [Assertions] The assertions instance. def print_test_feedback(output, assertions) duration = @clock.duration rate = assertions.count / duration total = assertions.total count = assertions.count if total < 10 or count < 10 output.puts "😭 You should write more tests and assertions!" # Statistics will be less meaningful with such a small amount of data, so give up: return end # Check whether there is at least, on average, one assertion (or more) per test: assertions_per_test = assertions.count / assertions.total if assertions_per_test < 1.0 output.puts "😩 Your tests don't have enough assertions (#{assertions_per_test.round(1)} < 1.0)!" end # Give some feedback about the number of tests: if total < 20 output.puts "🥲 You should write more tests (#{total}/20)!" elsif total < 50 output.puts "🙂 Your test suite is starting to shape up, keep on at it (#{total}/50)!" elsif total < 100 output.puts "😀 Your test suite is maturing, keep on at it (#{total}/100)!" else output.puts "🤩 Your test suite is amazing!" end # Give some feedback about the performance of the tests: if rate < 10.0 output.puts "💔 Ouch! Your test suite performance is painful (#{rate.round(1)} < 10)!" elsif rate < 100.0 output.puts "💩 Oops! Your test suite performance could be better (#{rate.round(1)} < 100)!" elsif rate < 1_000.0 output.puts "💪 Good job! Your test suite has good performance (#{rate.round(1)} < 1000)!" elsif rate < 10_000.0 output.puts "🎉 Great job! Your test suite has excellent performance (#{rate.round(1)} < 10000)!" else output.puts "🔥 Wow! Your test suite has outstanding performance (#{rate.round(1)} >= 10000.0)!" end end # Print information about slow tests. # @parameter output [Output] The output handler. # @parameter assertions [Assertions] The assertions instance. # @parameter threshold [Float] The threshold in seconds for considering a test slow. def print_slow_tests(output, assertions, threshold = 0.1) slowest_tests = assertions.passed.select{|test| test.clock > threshold}.sort_by(&:clock).reverse! if slowest_tests.empty? output.puts "🐇 No slow tests found! Well done!" else output.puts "🐢 Slow tests:" slowest_tests.each do |test| output.puts "\t", :variable, test.clock, :reset, ": ", test.target end end end # Print a list of assertions. # @parameter output [Output] The output handler. # @parameter title [String] The title to print. # @parameter assertions [Array] The assertions to print. def print_assertions(output, title, assertions) if assertions.any? output.puts output.puts title assertions.each do |assertion| output.append(assertion.output) end end end # Print failed and errored assertions. # @parameter output [Output] The output handler. # @parameter assertions [Assertions] The assertions instance. def print_failed_assertions(output, assertions) print_assertions(output, "🤔 Failed assertions:", assertions.failed) print_assertions(output, "🔥 Errored assertions:", assertions.errored) end end end sus-0.35.2/lib/sus/filter.rb0000644000004100000410000000552615152126155015650 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus # Provides a way to filter the registry according to the suffix on loaded paths. # # A test has an identity, e.g. the file and line number on which it's defined. # # A filter takes an identity, decomposes it into a file and suffix, loads the file, and registers the filter suffix. # # When the filter is used to enumerate the registry, it will only return the tests that match the suffix. class Filter # Represents an index of contexts by their identity keys. class Index # Initialize a new Index. def initialize @contexts = {} end # @attribute [Hash] A hash mapping identity keys to contexts. attr :contexts # Add all children from a parent context to the index. # @parameter parent [Object] The parent context. def add(parent) parent.children&.each do |identity, child| insert(identity, child) add(child) end end # Insert a context into the index. # @parameter identity [Identity] The identity of the context. # @parameter context [Object] The context to index. # @raises [KeyError] If a context with the same key already exists. def insert(identity, context) key = identity.key if existing_context = @contexts[key] raise KeyError, "Assigning context to existing key: #{key.inspect}!" else @contexts[key] = context end end # Look up a context by its key. # @parameter key [String] The identity key. # @returns [Object, nil] The context if found. def [] key @contexts[key] end end # Initialize a new Filter. # @parameter registry [Registry] The registry to filter. def initialize(registry = Registry.new) @registry = registry @index = nil @keys = Array.new end # Load a target path, optionally with a filter suffix. # @parameter target [String] The target path, optionally with a ":suffix" filter. def load(target) path, filter = target.split(":", 2) @registry.load(path) if filter @keys << target end end # Iterate over filtered test cases. # @yields {|test| ...} Each test case that matches the filter. def each(&block) if @keys.any? @index = Index.new @index.add(@registry) @keys.each do |key| if target = @index[key] yield target end end else @registry.each(&block) end end # Execute filtered tests. # @parameter assertions [Assertions] Optional assertions instance to use. # @returns [Assertions] The assertions instance with results. def call(assertions = Assertions.default) if @keys.any? @index = Index.new @index.add(@registry) @keys.each do |key| @index[key]&.call(assertions) end else @registry.call(assertions) end return assertions end end end sus-0.35.2/lib/sus/with.rb0000644000004100000410000000440115152126155015325 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus # Represents a test context with specific conditions or variables. module With extend Context # @attribute [String] The subject description of this context. attr_accessor :subject # @attribute [Hash] The variables available in this context. attr_accessor :variables # Build a new with block class. # @parameter parent [Class] The parent context class. # @parameter subject [String] The subject description. # @parameter variables [Hash] Variables to make available in the context. # @parameter unique [Boolean] Whether the identity should be unique. # @yields {...} Optional block containing nested tests. # @returns [Class] A new with block class. def self.build(parent, subject, variables, unique: true, &block) base = Class.new(parent) base.singleton_class.prepend(With) base.children = Hash.new base.subject = subject base.description = subject base.identity = Identity.nested(parent.identity, base.description, unique: unique) base.set_temporary_name("#{self}[#{base.description}]") base.variables = variables base.define_method(:description, ->{subject}) variables.each do |key, value| base.define_method(key, ->{value}) end if block_given? base.class_exec(&block) end return base end # Print a representation of this with block. # @parameter output [Output] The output target. def print(output) self.superclass.print(output) output.write( " with ", :with, self.description, :reset, # " ", :variables, self.variables.inspect ) end end module Context # Define a new test context with specific conditions or variables. # @parameter subject [String | Nil] Optional subject description. If nil, uses variables.inspect. # @parameter unique [Boolean] Whether the identity should be unique. # @parameter variables [Hash] Variables to make available in the context. # @yields {...} Optional block containing nested tests. def with(subject = nil, unique: true, **variables, &block) subject ||= variables.inspect add With.build(self, subject, variables, unique: unique, &block) end end end sus-0.35.2/lib/sus/have/0000755000004100000410000000000015152126155014751 5ustar www-datawww-datasus-0.35.2/lib/sus/have/all.rb0000644000004100000410000000214515152126155016050 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022, by Samuel Williams. module Sus module Have # Represents a predicate that checks if the subject matches all of the given predicates. class All # Initialize a new All predicate. # @parameter predicates [Array] The predicates to check. def initialize(predicates) @predicates = predicates end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) first = true output.write("have {") @predicates.each do |predicate| if first first = false else output.write(", ") end output.write(predicate) end output.write("}") end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| @predicates.each do |predicate| predicate.call(assertions, subject) end end end end end end sus-0.35.2/lib/sus/have/any.rb0000644000004100000410000000260515152126155016070 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022, by Samuel Williams. module Sus module Have # Represents a predicate that checks if the subject matches any of the given predicates. class Any # Initialize a new Any predicate. # @parameter predicates [Array] The predicates to check. def initialize(predicates) @predicates = predicates end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) first = true output.write("have any {") @predicates.each do |predicate| if first first = false else output.write(", ") end output.write(predicate) end output.write("}") end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| @predicates.each do |predicate| predicate.call(assertions, subject) end if assertions.passed.any? # We don't care about any failures in this case, as long as one of the values passed: assertions.failed.clear else # Nothing passed, so we failed: assertions.assert(false, "could not find any matching value") end end end end end end sus-0.35.2/lib/sus/fixtures/0000755000004100000410000000000015152126155015677 5ustar www-datawww-datasus-0.35.2/lib/sus/fixtures/temporary_directory_context.rb0000644000004100000410000000116115152126155024075 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "tmpdir" module Sus module Fixtures # Provides a temporary directory context for tests that need isolated file system access. module TemporaryDirectoryContext # Set up a temporary directory before the test and clean it up after. # @yields {|&block| ...} The test block to execute. def around(&block) Dir.mktmpdir do |root| @root = root super(&block) @root = nil end end # @attribute [String] The path to the temporary directory root. attr :root end end end sus-0.35.2/lib/sus/let.rb0000644000004100000410000000124415152126155015140 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus module Context # Define a lazy variable that is evaluated when first accessed. # @parameter name [Symbol] The name of the variable. # @yields {...} The block that computes the variable value. def let(name, &block) instance_variable = :"@#{name}" self.define_method(name) do if self.instance_variable_defined?(instance_variable) return self.instance_variable_get(instance_variable) else self.instance_variable_set(instance_variable, self.instance_exec(&block)) end end end end end sus-0.35.2/lib/sus/be.rb0000644000004100000410000001655015152126155014750 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus # Represents a predicate matcher that can be used with `expect(...).to be(...)`. class Be # Represents a logical AND combination of multiple predicates. class And # Initialize a new AND predicate. # @parameter predicates [Array] The predicates to combine with AND logic. def initialize(predicates) @predicates = predicates end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) @predicates.each_with_index do |predicate, index| if index > 0 output.write(" and ", :reset) end predicate.print(output) end end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) @predicates.each do |predicate| predicate.call(assertions, subject) end end # Combine this predicate with another using AND logic. # @parameter other [Object] Another predicate to combine. # @returns [And] A new AND predicate. def &(other) And.new(@predicates + [other]) end # Combine this predicate with another using OR logic. # @parameter other [Object] Another predicate to combine. # @returns [Or] A new OR predicate. def |(other) Or.new(self, other) end end # Represents a logical OR combination of multiple predicates. class Or # Initialize a new OR predicate. # @parameter predicates [Array] The predicates to combine with OR logic. def initialize(predicates) @predicates = predicates end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) @predicates.each_with_index do |predicate, index| if index > 0 output.write(" or ", :reset) end predicate.print(output) end end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| @predicates.each do |predicate| predicate.call(assertions, subject) end if assertions.passed.any? # At least one passed, so we don't care about failures: assertions.failed.clear else # Nothing passed, so we failed: assertions.assert(false, "could not find any matching predicate") end end end # Combine this predicate with another using AND logic. # @parameter other [Object] Another predicate to combine. # @returns [And] A new AND predicate. def &(other) And.new(self, other) end # Combine this predicate with another using OR logic. # @parameter other [Object] Another predicate to combine. # @returns [Or] A new OR predicate. def |(other) Or.new(@predicates + [other]) end end # Initialize a new Be predicate. # @parameter arguments [Array] The method name and arguments to call on the subject. def initialize(*arguments) @arguments = arguments end # Combine this predicate with another using OR logic. # @parameter other [Object] Another predicate to combine. # @returns [Or] A new OR predicate. def |(other) Or.new([self, other]) end # Combine this predicate with others using OR logic. # @parameter others [Array] Other predicates to combine. # @returns [Or] A new OR predicate. def or(*others) Or.new([self, *others]) end # Combine this predicate with another using AND logic. # @parameter other [Object] Another predicate to combine. # @returns [And] A new AND predicate. def &(other) And.new([self, other]) end # Combine this predicate with others using AND logic. # @parameter others [Array] Other predicates to combine. # @returns [And] A new AND predicate. def and(*others) And.new([self, *others]) end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) operation, *arguments = *@arguments output.write("be ", :be, operation.to_s, :reset) if arguments.any? output.write(" ", :variable, arguments.map(&:inspect).join, :reset) end end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| assertions.assert(subject.public_send(*@arguments)) end end class << self # Create a predicate that checks equality. # @parameter value [Object] The value to compare against. # @returns [Be] A new Be predicate. def == value Be.new(:==, value) end # Create a predicate that checks inequality. # @parameter value [Object] The value to compare against. # @returns [Be] A new Be predicate. def != value Be.new(:!=, value) end # Create a predicate that checks if the subject is greater than a value. # @parameter value [Object] The value to compare against. # @returns [Be] A new Be predicate. def > value Be.new(:>, value) end # Create a predicate that checks if the subject is greater than or equal to a value. # @parameter value [Object] The value to compare against. # @returns [Be] A new Be predicate. def >= value Be.new(:>=, value) end # Create a predicate that checks if the subject is less than a value. # @parameter value [Object] The value to compare against. # @returns [Be] A new Be predicate. def < value Be.new(:<, value) end # Create a predicate that checks if the subject is less than or equal to a value. # @parameter value [Object] The value to compare against. # @returns [Be] A new Be predicate. def <= value Be.new(:<=, value) end # Create a predicate that checks if the subject matches a pattern. # @parameter value [Regexp, Object] The pattern to match against. # @returns [Be] A new Be predicate. def =~ value Be.new(:=~, value) end # Create a predicate that checks case equality. # @parameter value [Object] The value to compare against. # @returns [Be] A new Be predicate. def === value Be.new(:===, value) end end # A predicate that checks if the subject is nil. NIL = Be.new(:nil?) end class Base # Create a Be predicate matcher. # @parameter arguments [Array] Optional method name and arguments to call on the subject. # @returns [Be, Class] A Be predicate if arguments are provided, otherwise the Be class. def be(*arguments) if arguments.any? Be.new(*arguments) else Be end end # Create a predicate that checks if the subject is an instance of a class. # @parameter klass [Class] The class to check against. # @returns [Be] A new Be predicate. def be_a(klass) Be.new(:is_a?, klass) end # Create a predicate that checks if the subject is nil. # @returns [Be] A Be predicate that checks for nil. def be_nil Be::NIL end # Create a predicate that checks object identity equality. # @parameter other [Object] The object to compare against. # @returns [Be] A new Be predicate. def be_equal(other) Be.new(:equal?, other) end end end sus-0.35.2/lib/sus/mock.rb0000644000004100000410000001173215152126155015310 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. require_relative "expect" module Sus # Represents a mock object that can intercept and replace method calls on a target object. class Mock # Initialize a new mock for the given target. # @parameter target [Object] The object to mock. def initialize(target) @target = target @interceptor = Module.new @target.singleton_class.prepend(@interceptor) end # @attribute [Object] The target object being mocked. attr :target # Print a representation of this mock. # @parameter output [Output] The output target. def print(output) output.write("mock ", :context, @target.inspect) end # Clear all mocked methods from the target. def clear @interceptor.instance_methods.each do |method_name| @interceptor.remove_method(method_name) end end # Replace a method implementation. # @parameter method [Symbol] The method name to replace. # @yields {|*arguments, **options, &block| ...} The replacement implementation. # @returns [Mock] Returns self for method chaining. def replace(method, &hook) execution_context = Thread.current @interceptor.define_method(method) do |*arguments, **options, &block| if execution_context == Thread.current hook.call(*arguments, **options, &block) else super(*arguments, **options, &block) end end return self end # Add a hook that runs before a method is called. # @parameter method [Symbol] The method name to hook. # @yields {|*arguments, **options, &block| ...} The hook to execute before the method. # @returns [Mock] Returns self for method chaining. def before(method, &hook) execution_context = Thread.current @interceptor.define_method(method) do |*arguments, **options, &block| hook.call(*arguments, **options, &block) if execution_context == Thread.current super(*arguments, **options, &block) end return self end # Add a hook that runs after a method is called. # @parameter method [Symbol] The method name to hook. # @yields {|result, *arguments, **options, &block| ...} The hook to execute after the method, receiving the result as the first argument. # @returns [Mock] Returns self for method chaining. def after(method, &hook) execution_context = Thread.current @interceptor.define_method(method) do |*arguments, **options, &block| result = super(*arguments, **options, &block) hook.call(result, *arguments, **options, &block) if execution_context == Thread.current return result end return self end # Wrap a method, yielding the original method as the first argument, so you can call it from within the hook. # @parameter method [Symbol] The method name to wrap. # @yields {|original, *arguments, **options, &block| ...} The wrapper implementation, receiving the original method as the first argument. def wrap(method, &hook) execution_context = Thread.current @interceptor.define_method(method) do |*arguments, **options, &block| if execution_context == Thread.current original = proc do |*arguments, **options| super(*arguments, **options) end hook.call(original, *arguments, **options, &block) else super(*arguments, **options, &block) end end end end # Provides mock management functionality for test cases. module Mocks # Clean up all mocks after the test completes. # @parameter error [Exception | Nil] The error that occurred, if any. def after(error = nil) super @mocks&.each_value(&:clear) end # Create or access a mock for the given target. # @parameter target [Object] The object to mock. # @yields {|mock| ...} Optional block to configure the mock. # @returns [Mock] The mock instance for the target. def mock(target) validate_mock!(target) mock = self.mocks[target] if block_given? yield mock end return mock end private # Error raised when attempting to mock a frozen object. MockTargetError = Class.new(StandardError) # Validate that the target can be mocked. # @parameter target [Object] The object to validate. # @raises [MockTargetError] If the target is frozen. def validate_mock!(target) if target.frozen? raise MockTargetError, "Cannot mock frozen object #{target.inspect}!" end end # Get the mocks hash, creating it if necessary. # @returns [Hash] A hash mapping targets to their mock instances. def mocks @mocks ||= Hash.new{|h,k| h[k] = Mock.new(k)}.compare_by_identity end end class Base # Create or access a mock for the given target. # @parameter target [Object] The object to mock. # @yields {|mock| ...} Optional block to configure the mock. # @returns [Mock] The mock instance for the target. def mock(target, &block) # Pull in the extra functionality: self.singleton_class.prepend(Mocks) # Redirect the method to the new functionality: self.mock(target, &block) end end end sus-0.35.2/lib/sus/base.rb0000644000004100000410000000423615152126155015272 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus # Represents the base test case class. Provides core functionality for test execution including hooks for setup and teardown. class Base # Initialize a new test case instance. # @parameter assertions [Assertions] The assertions instance used to track test results. def initialize(assertions) @__assertions__ = assertions end # @returns [String] A string representation of the test case. def inspect "\#" end # A hook which is called before the test is executed. # # If you override this method, you must call super. def before end # A hook which is called after the test is executed. # # If you override this method, you must call super. def after(error = nil) end # Wrap logic around the test being executed. # # Invokes the before hook, then the block, then the after hook. # # @yields {...} the block which should execute a test. def around(&block) self.before return block.call rescue => error raise ensure self.after(error) end # Make an assertion about a condition. # @parameter condition [Boolean] The condition to assert. # @parameter message [String | Nil] Optional message describing the assertion. def assert(...) @__assertions__.assert(...) end # Print an informational message during test execution. # @parameter message [String | Nil] The message to print, or a block that returns a message. def inform(...) @__assertions__.inform(...) end end # Create a new base test class with the given description. # @parameter description [String | Nil] Optional description for the test class. # @parameter root [String | Nil] Optional root path for the test identity. # @returns [Class] A new test class that extends {Base}. def self.base(description = nil, root: nil) base = Class.new(Base) base.extend(Context) base.identity = Identity.new(root) if root base.description = description base.set_temporary_name("#{self}[#{description}]") return base end end sus-0.35.2/lib/sus/include_context.rb0000644000004100000410000000113415152126155017541 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus module Context # Include a shared context into the current context, along with any arguments or options. # # @parameter shared [Sus::Shared] The shared context to include. # @parameter arguments [Array] The arguments to pass to the shared context. # @parameter options [Hash] The options to pass to the shared context. def include_context(shared, *arguments, **options) self.class_exec(*arguments, **options, &shared.block) end end end sus-0.35.2/lib/sus/receive.rb0000644000004100000410000002156115152126155016002 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. require_relative "respond_to" module Sus # Represents an expectation that a method will be called on an object. class Receive # Initialize a new Receive expectation. # @parameter base [Object] The base object (usually self from Base). # @parameter method [Symbol] The method name to expect. # @yields {...} Optional block that returns the value to return from the method. def initialize(base, method, &block) @base = base @method = method @times = Times.new @arguments = nil @options = nil @block = nil @returning = block end # Print a representation of this expectation. # @parameter output [Output] The output target. def print(output) output.write("receive ", :variable, @method.to_s, :reset) end # Specify that the method should be called with specific arguments. # @parameter predicate [Object] The predicate to match against the arguments. # @returns [Receive] Returns self for method chaining. def with_arguments(predicate) @arguments = WithArguments.new(predicate) return self end # Specify that the method should be called with specific keyword options. # @parameter predicate [Object] The predicate to match against the options. # @returns [Receive] Returns self for method chaining. def with_options(predicate) @options = WithOptions.new(predicate) return self end # Specify that the method should be called with a block. # @parameter predicate [Object] Optional predicate to match against the block. # @returns [Receive] Returns self for method chaining. def with_block(predicate = Be.new(:!=, nil)) @block = WithBlock.new(predicate) return self end # Specify that the method should be called with specific arguments and options. # @parameter arguments [Array] The positional arguments to match. # @parameter options [Hash] The keyword arguments to match. # @returns [Receive] Returns self for method chaining. def with(*arguments, **options) with_arguments(Be.new(:==, arguments)) if arguments.any? with_options(Be.new(:==, options)) if options.any? return self end # Specify that the method should be called exactly once. # @returns [Receive] Returns self for method chaining. def once @times = Times.new(Be.new(:==, 1)) return self end # Specify that the method should be called exactly twice. # @returns [Receive] Returns self for method chaining. def twice @times = Times.new(Be.new(:==, 2)) return self end # Specify a predicate to match against the call count. # @parameter predicate [Object] The predicate to match against the call count. # @returns [Receive] Returns self for method chaining. def with_call_count(predicate) @times = Times.new(predicate) return self end # Specify the value to return when the method is called. # @parameter returning [Array] Values to return. If one value, returns it directly; if multiple, returns an array. # @yields {...} Optional block that computes the return value. # @returns [Receive] Returns self for method chaining. # @raises [ArgumentError] If both values and a block are provided. def and_return(*returning, &block) if block_given? if returning.any? raise ArgumentError, "Cannot specify both a block and returning values." end @returning = block elsif returning.size == 1 @returning = proc{returning.first} else @returning = proc{returning} end return self end # Specify that the method should raise an exception when called. # @parameter exception [Class, String] The exception class or message to raise. # @returns [Receive] Returns self for method chaining. def and_raise(...) @returning = proc do raise(...) end return self end # Validate the method call arguments, options, and block. # @parameter mock [Mock] The mock instance. # @parameter assertions [Assertions] The assertions instance. # @parameter arguments [Array] The positional arguments. # @parameter options [Hash] The keyword arguments. # @parameter block [Proc, nil] The block argument. def validate(mock, assertions, arguments, options, block) return unless @arguments or @options or @block assertions.nested(self) do |assertions| @arguments.call(assertions, arguments) if @arguments @options.call(assertions, options) if @options @block.call(assertions, block) if @block end end # Evaluate this expectation against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The object to expect the method call on. def call(assertions, subject) assertions.nested(self) do |assertions| mock = @base.mock(subject) called = 0 if call_original? mock.before(@method) do |*arguments, **options, &block| called += 1 validate(mock, assertions, arguments, options, block) end else mock.replace(@method) do |*arguments, **options, &block| called += 1 validate(mock, assertions, arguments, options, block) next @returning.call(*arguments, **options, &block) end end if @times assertions.defer do @times.call(assertions, called) end end end end # @returns [Boolean] Whether the original method should be called. def call_original? @returning.nil? end # Represents a constraint on method call arguments. class WithArguments # Initialize a new WithArguments constraint. # @parameter predicate [Object] The predicate to match against arguments. def initialize(predicate) @predicate = predicate end # Print a representation of this constraint. # @parameter output [Output] The output target. def print(output) output.write("with arguments ", @predicate) end # Evaluate this constraint against arguments. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Array] The arguments to check. def call(assertions, subject) assertions.nested(self) do |assertions| Expect.new(assertions, subject).to(@predicate) end end end # Represents a constraint on method call keyword options. class WithOptions # Initialize a new WithOptions constraint. # @parameter predicate [Object] The predicate to match against options. def initialize(predicate) @predicate = predicate end # Print a representation of this constraint. # @parameter output [Output] The output target. def print(output) output.write("with options ", @predicate) end # Evaluate this constraint against options. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Hash] The options to check. def call(assertions, subject) assertions.nested(self) do |assertions| Expect.new(assertions, subject).to(@predicate) end end end # Represents a constraint on method call block argument. class WithBlock # Initialize a new WithBlock constraint. # @parameter predicate [Object] The predicate to match against the block. def initialize(predicate) @predicate = predicate end # Print a representation of this constraint. # @parameter output [Output] The output target. def print(output) output.write("with block", @predicate) end # Evaluate this constraint against a block. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Proc, nil] The block to check. def call(assertions, subject) assertions.nested(self) do |assertions| Expect.new(assertions, subject).not.to(Be == nil) end end end # Represents a constraint on method call count. class Times # A predicate that matches at least one call. AT_LEAST_ONCE = Be.new(:>=, 1) # Initialize a new Times constraint. # @parameter condition [Object] The predicate to match against the call count. def initialize(condition = AT_LEAST_ONCE) @condition = condition end # Print a representation of this constraint. # @parameter output [Output] The output target. def print(output) output.write("with call count ", @condition) end # Evaluate this constraint against a call count. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Integer] The call count to check. def call(assertions, subject) assertions.nested(self) do |assertions| Expect.new(assertions, subject).to(@condition) end end end end class Base # Create an expectation that a method will be called. # @parameter method [Symbol] The method name to expect. # @yields {...} Optional block that returns the value to return from the method. # @returns [Receive] A new Receive expectation. def receive(method, &block) Receive.new(self, method, &block) end end end sus-0.35.2/lib/sus/respond_to.rb0000644000004100000410000000745115152126155016536 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. module Sus # Represents a predicate that checks if an object responds to a method. class RespondTo # Represents a constraint on method parameters. class WithParameters # Initialize a new WithParameters constraint. # @parameter parameters [Array(Symbol)] List of method parameters in the expected order, must include at least all required parameters but can also list optional parameters. def initialize(parameters) @parameters = parameters end # Evaluate this constraint against method parameters. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Array] The method parameters to check. def call(assertions, subject) parameters = @parameters.dup assertions.nested(self) do |assertions| expected_name = parameters.shift subject.each do |type, name| case type when :req assertions.assert(name == expected_name, "parameter #{expected_name} is required, but was #{name}") when :opt break if expected_name.nil? assertions.assert(name == expected_name, "parameter #{expected_name} is specified, but was #{name}") else break end end end end end # Represents a constraint on method keyword options. class WithOptions # Initialize a new WithOptions constraint. # @parameter options [Array(Symbol)] The option names that should be present. def initialize(options) @options = options end # Print a representation of this constraint. # @parameter output [Output] The output target. def print(output) output.write("with options ", :variable, @options.inspect) end # Evaluate this constraint against method parameters. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Array] The method parameters to check. def call(assertions, subject) options = {} @options.each{|name| options[name] = nil} subject.each do |type, name| options[name] = type end assertions.nested(self) do |assertions| options.each do |name, type| assertions.assert(type != nil, "option #{name}: is required") end end end end # Initialize a new RespondTo predicate. # @parameter method [Symbol, String] The method name to check for. def initialize(method) @method = method @parameters = nil @options = nil end # Specify that the method should have specific keyword options. # @parameter options [Array(Symbol)] The option names that should be present. # @returns [RespondTo] Returns self for method chaining. def with_options(*options) @options = WithOptions.new(options) return self end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("respond to ", :variable, @method.to_s, :reset) end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| condition = subject.respond_to?(@method) assertions.assert(condition, self) if condition and (@parameters or @options) parameters = subject.method(@method).parameters @parameters.call(assertions, parameters) if @parameters @options.call(assertions, parameters) if @options end end end end class Base # Create a predicate that checks if the subject responds to a method. # @parameter method [Symbol, String] The method name to check for. # @returns [RespondTo] A new RespondTo predicate. def respond_to(method) RespondTo.new(method) end end end sus-0.35.2/lib/sus/integrations.rb0000644000004100000410000000024015152126155017055 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023, by Samuel Williams. module Sus # @namespace module Integrations end end sus-0.35.2/lib/sus/it_behaves_like.rb0000644000004100000410000000412015152126155017465 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus # Represents a test context that behaves like a shared context. module ItBehavesLike extend Context # @attribute [Shared] The shared context being used. attr_accessor :shared # Build a new ItBehavesLike context. # @parameter parent [Class] The parent context class. # @parameter shared [Shared] The shared context to use. # @parameter arguments [Array | Nil] Optional arguments to pass to the shared context. # @parameter unique [Boolean] Whether the identity should be unique. # @yields {...} Optional block to execute before the shared context. # @returns [Class] A new test class that behaves like the shared context. def self.build(parent, shared, arguments = nil, unique: false, &block) base = Class.new(parent) base.singleton_class.prepend(ItBehavesLike) base.children = Hash.new base.description = shared.name base.identity = Identity.nested(parent.identity, base.description, unique: unique) base.set_temporary_name("#{self}[#{base.description}]") # User provided block is evaluated first, so that it can provide default behaviour for the shared context: if block_given? base.class_exec(*arguments, &block) end base.class_exec(*arguments, &shared.block) return base end # Print a representation of this context. # @parameter output [Output] The output target. def print(output) self.superclass.print(output) output.write(" it behaves like ", :describe, self.description, :reset) end end module Context # Define a test context that behaves like a shared context. # @parameter shared [Shared] The shared context to use. # @parameter arguments [Array] Optional arguments to pass to the shared context. # @parameter options [Hash] Additional options. # @yields {...} Optional block to execute before the shared context. def it_behaves_like(shared, *arguments, **options, &block) add ItBehavesLike.build(self, shared, arguments, **options, &block) end end end sus-0.35.2/lib/sus/have.rb0000644000004100000410000001200515152126155015274 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2024, by Samuel Williams. require_relative "have/all" require_relative "have/any" module Sus # Represents predicates for checking collections and object attributes. module Have # Represents a predicate that checks if a hash has a specific key. class Key # Initialize a new Key predicate. # @parameter name [Object] The key name to check for. # @parameter predicate [Object, nil] Optional predicate to apply to the key's value. def initialize(name, predicate = nil) @name = name @predicate = predicate end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("key ", :variable, @name.inspect, :reset, " ", @predicate, :reset) end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate (should be a hash). def call(assertions, subject) # We want to group all the assertions in to a distinct group: assertions.nested(self, distinct: true) do |assertions| assertions.assert(subject.key?(@name), "has key") if @predicate Expect.new(assertions, subject[@name]).to(@predicate) end end end end # Represents a predicate that checks if an object has a specific attribute. class Attribute # Initialize a new Attribute predicate. # @parameter name [Symbol, String] The attribute name to check for. # @parameter predicate [Object] The predicate to apply to the attribute's value. def initialize(name, predicate) @name = name @predicate = predicate end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("attribute ", :variable, @name.to_s, :reset, " ", @predicate, :reset) end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self, distinct: true) do |assertions| assertions.assert(subject.respond_to?(@name), "has attribute") if @predicate Expect.new(assertions, subject.public_send(@name)).to(@predicate) end end end end # Represents a predicate that checks if a collection has a value matching a predicate. class Value # Initialize a new Value predicate. # @parameter predicate [Object, nil] The predicate to apply to each value in the collection. def initialize(predicate) @predicate = predicate end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("value ", @predicate, :reset) end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate (should be enumerable). def call(assertions, subject) index = 0 subject.each do |value| assertions.nested("[#{index}] = #{value.inspect}", distinct: true) do |assertions| @predicate&.call(assertions, value) end index += 1 end end end end class Base # Create a predicate that checks if the subject has all of the given predicates. # @parameter predicates [Array] The predicates to check. # @returns [Have::All] A Have::All predicate. def have(*predicates) Have::All.new(predicates) end # Create a predicate that checks if the subject (hash) has the specified keys. # @parameter keys [Array] Keys to check for. Can be symbols/strings or hashes with key-predicate pairs. # @returns [Have::All] A Have::All predicate. def have_keys(*keys) predicates = [] keys.each do |key| if key.is_a?(Hash) key.each do |key, predicate| predicates << Have::Key.new(key, predicate) end else predicates << Have::Key.new(key) end end Have::All.new(predicates) end # Create a predicate that checks if the subject has the specified attributes with matching values. # @parameter attributes [Hash] A hash of attribute names to predicates. # @returns [Have::All] A Have::All predicate. def have_attributes(**attributes) predicates = attributes.map do |key, value| Have::Attribute.new(key, value) end Have::All.new(predicates) end # Create a predicate that checks if the subject matches any of the given predicates. # @parameter predicates [Array] The predicates to check. # @returns [Have::Any] A Have::Any predicate. def have_any(*predicates) Have::Any.new(predicates) end # Create a predicate that checks if the subject (collection) has any value matching the predicate. # @parameter predicate [Object] The predicate to apply to each value. # @returns [Have::Any] A Have::Any predicate. def have_value(predicate) Have::Any.new([Have::Value.new(predicate)]) end end end sus-0.35.2/lib/sus/assertions.rb0000644000004100000410000003662015152126155016554 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2025, by Samuel Williams. require_relative "output" require_relative "clock" require_relative "output/backtrace" module Sus # Represents a collection of test assertions and their results. Tracks passed, failed, skipped, and errored assertions. class Assertions # Create a new assertions instance with default options. # @parameter options [Hash] Options to pass to {#initialize}. # @returns [Assertions] A new assertions instance. def self.default(**options) self.new(**options) end # Initialize a new assertions instance. # @parameter identity [Identity, nil] The identity used to identify this set of assertions. # @parameter target [Object, nil] The specific target of the assertions, e.g. the test case or nested test assertions. # @parameter output [Output] The output buffer used to capture output from the assertions. # @parameter inverted [Boolean] Whether the assertions are inverted with respect to the parent. # @parameter orientation [Boolean] Whether the assertions are positive or negative in general. # @parameter isolated [Boolean] Whether this set of assertions is isolated from the parent. # @parameter distinct [Boolean] Whether this set of assertions should be treated as a single statement. # @parameter measure [Boolean] Whether to measure execution time. # @parameter verbose [Boolean] Whether to output verbose information. def initialize(identity: nil, target: nil, output: Output.buffered, inverted: false, orientation: true, isolated: false, distinct: false, measure: false, verbose: false) # In theory, the target could carry the identity of the assertion group, but it's not really necessary, so we just handle it explicitly and pass it into any nested assertions. @identity = identity @target = target @output = output @inverted = inverted @orientation = orientation @isolated = isolated @distinct = distinct @verbose = verbose if measure @clock = Clock.start! else @clock = nil end @passed = Array.new @failed = Array.new @deferred = Array.new @skipped = Array.new @errored = Array.new @count = 0 end # @attribute [Identity, nil] The identity that is used to identify this set of assertions. attr :identity # @attribute [Object, nil] The specific target of the assertions, e.g. the test case or nested test assertions. attr :target # @attribute [Output] The output buffer used to capture output from the assertions. attr :output # @attribute [Integer, nil] The nesting level of this set of assertions. attr :level # @attribute [Boolean] Whether this set of assertions is inverted, i.e. the assertions are expected to fail relative to the parent. Used for grouping assertions and ensuring they are added to the parent passed/failed array correctly. attr :inverted # @attribute [Boolean] The absolute orientation of this set of assertions, i.e. whether the assertions are expected to pass or fail regardless of the parent. Used for correctly formatting the output. attr :orientation # @attribute [Boolean] Whether this set of assertions is isolated from the parent. This is used to ensure that any deferred assertions are completed before the parent is completed. This is used by `receive` assertions which are deferred until the user code of the test has completed. attr :isolated # @attribute [Boolean] Distinct is used to identify a set of assertions as a single statement for the purpose of user feedback. It's used by top level ensure statements to ensure that error messages are captured and reported on those statements. attr :distinct # @attribute [Boolean] Whether to output verbose information. attr :verbose # @attribute [Clock, nil] The clock used to measure execution time, if measurement is enabled. attr :clock # @attribute [Array] Nested assertions that have passed. attr :passed # @attribute [Array] Nested assertions that have failed. attr :failed # @attribute [Array] Nested assertions that have been deferred. attr :deferred # @attribute [Array] Nested assertions that have been skipped. attr :skipped # @attribute [Array] Nested assertions that have errored. attr :errored # @attribute [Integer] The total number of assertions performed. attr :count # @returns [String] A string representation of the assertions instance. def inspect "\#<#{self.class} #{@passed.size} passed #{@failed.size} failed #{@deferred.size} deferred #{@skipped.size} skipped #{@errored.size} errored>" end # @returns [Hash] A hash containing the output text and location of the assertions. def message { text: @output.string, location: @identity&.to_location } end # @returns [Integer] The total number of assertions (passed, failed, deferred, skipped, and errored). def total @passed.size + @failed.size + @deferred.size + @skipped.size + @errored.size end # Print a summary of the assertions to the output. # @parameter output [Output] The output target. # @parameter verbose [Boolean] Whether to include verbose information. def print(output, verbose: @verbose) if verbose && @target @target.print(output) output.write(": ") end if @count.zero? output.write("0 assertions") else if @passed.any? output.write(:passed, @passed.size, " passed", :reset, " ") end if @failed.any? output.write(:failed, @failed.size, " failed", :reset, " ") end if @deferred.any? output.write(:deferred, @deferred.size, " deferred", :reset, " ") end if @skipped.any? output.write(:skipped, @skipped.size, " skipped", :reset, " ") end if @errored.any? output.write(:errored, @errored.size, " errored", :reset, " ") end output.write("out of ", self.total, " total (", @count, " assertions)") end end # Print a message to the output buffer. # @parameter message [Array] The message parts to print. def puts(*message) @output.puts(:indent, *message) end # @returns [Boolean] Whether there are no assertions (passed, failed, deferred, skipped, or errored). def empty? @passed.empty? and @failed.empty? and @deferred.empty? and @skipped.empty? and @errored.empty? end # @returns [Boolean] Whether all assertions passed and none errored. def passed? if @inverted # Inverted assertions: @failed.any? and @errored.empty? else # Normal assertions: @failed.empty? and @errored.empty? end end # @returns [Boolean] Whether any assertions failed or errored. def failed? !self.passed? end # @returns [Boolean] Whether any assertions errored. def errored? @errored.any? end # Represents a single assertion result. class Assert # Initialize a new assertion result. # @parameter identity [Identity, nil] The identity of the assertion. # @parameter backtrace [Array] The backtrace where the assertion was made. # @parameter assertions [Assertions] The assertions instance that contains this assertion. def initialize(identity, backtrace, assertions) @identity = identity @backtrace = backtrace @assertions = assertions end # @attribute [Identity, nil] The identity of the assertion. attr :identity # @attribute [Array] The backtrace where the assertion was made. attr :backtrace # @attribute [Assertions] The assertions instance that contains this assertion. attr :assertions # @yields {|assert| ...} Yields this assertion as a failure. def each_failure(&block) yield self end # @returns [Hash] A hash containing the output text and location of the assertion. def message { # It's possible that several Assert instances might share the same output text. This is because the output is buffered for each test and each top-level test expectation. text: @assertions.output.string, location: @identity&.to_location } end end # Make an assertion about a condition. # @parameter condition [Boolean] The condition to assert. # @parameter message [String | Nil] Optional message describing the assertion. def assert(condition, message = nil) @count += 1 identity = @identity&.scoped backtrace = Output::Backtrace.first(identity) assert = Assert.new(identity, backtrace, self) if condition @passed << assert @output.assert(condition, @orientation, message || "assertion passed", backtrace) else @failed << assert @output.assert(condition, @orientation, message || "assertion failed", backtrace) end end # Iterate over all failures in this assertions instance. # @yields {|failure| ...} Each failure (failed assertion or error). # @returns [Enumerator] An enumerator if no block is given. def each_failure(&block) return to_enum(__method__) unless block_given? if self.failed? and @distinct return yield(self) end @failed.each do |assertions| assertions.each_failure(&block) end @errored.each do |assertions| assertions.each_failure(&block) end end # Skip this set of assertions with a reason. # @parameter reason [String] The reason for skipping. def skip(reason) @output.skip(reason, @identity&.scoped) @skipped << self end # Print an informational message during test execution. # @parameter message [String | Nil] The message to print, or a block that returns a message. def inform(message = nil) if message.nil? and block_given? begin message = yield rescue => error message = error.full_message end end @output.inform(message, @identity&.scoped) end # Add a deferred assertion that will be resolved later. # @yields {|assertions| ...} The block that will be called to resolve the deferred assertion. def defer(&block) @deferred << block end # @returns [Boolean] Whether there are any deferred assertions. def deferred? @deferred.any? end # Resolve all deferred assertions in order. def resolve! @output.indented do while block = @deferred.shift block.call(self) end end end # Represents an error that occurred during test execution. class Error # Initialize a new error result. # @parameter identity [Identity, nil] The identity where the error occurred. # @parameter error [Exception] The exception that was raised. def initialize(identity, error) @identity = identity @error = error end # @attribute [Identity, nil] The identity where the error occurred. attr :identity # @attribute [Exception] The exception that was raised. attr :error # @yields {|error| ...} Yields this error as a failure. def each_failure(&block) yield self end # @returns [Hash] A hash containing the error message and location. def message { text: @error.full_message, location: @identity&.to_location } end end # Record an error that occurred during test execution. # @parameter error [Exception] The exception that was raised. def error!(error) identity = @identity&.scoped(error.backtrace_locations) @errored << Error.new(identity, error) # TODO consider passing `identity`. @output.error(error, @identity) end # Create a nested set of assertions. # @parameter target [Object] The target object for the nested assertions. # @parameter identity [Identity, nil] The identity for the nested assertions. # @parameter isolated [Boolean] Whether the nested assertions are isolated from the parent. # @parameter distinct [Boolean] Whether the nested assertions should be treated as a single statement. # @parameter inverted [Boolean] Whether the nested assertions are inverted. # @parameter options [Hash] Additional options to pass to the nested assertions instance. # @yields {|assertions| ...} The nested assertions instance. # @returns [Object] The result of the block. def nested(target, identity: @identity, isolated: false, distinct: false, inverted: false, **options) result = nil # Isolated assertions need to have buffered output so they can be replayed if they fail: if isolated or distinct output = @output.buffered else output = @output end # Inverting a nested assertions causes the orientation to flip: if inverted orientation = !@orientation else orientation = @orientation end output.puts(:indent, target) assertions = self.class.new(identity: identity, target: target, output: output, isolated: isolated, inverted: inverted, orientation: orientation, distinct: distinct, verbose: @verbose, **options) output.indented do begin result = yield(assertions) rescue StandardError => error assertions.error!(error) end end # Some assertions are deferred until the end of the test, e.g. expecting a method to be called. This scope is managed by the {add} method. If there are no deferred assertions, then we can add the child assertions right away. Otherwise, we append the child assertions to our own list of deferred assertions. When an assertions instance is marked as `isolated`, it will force all deferred assertions to be resolved. It's also at this time, we should conclude measuring the duration of the test. assertions.resolve_into(self) return result end # Add child assertions that were nested to this instance. # @parameter assertions [Assertions] The child assertions to add. def add(assertions) # All child assertions should be resolved by this point: raise "Nested assertions must be fully resolved!" if assertions.deferred? if assertions.append? # If we are isolated, we merge all child assertions into the parent as a single entity: append!(assertions) else # Otherwise, we append all child assertions into the parent assertions: merge!(assertions) end end protected def resolve_into(parent) # If the assertions should be an isolated group, make sure any deferred assertions are resolved: if @isolated and self.deferred? self.resolve! end # Check if the child assertions are deferred, and if so, add them to our own list of deferred assertions: if self.deferred? parent.defer do output.puts(:indent, @target) self.resolve! @clock&.stop! parent.add(self) end else @clock&.stop! parent.add(self) end end # Whether the child assertions should be merged into the parent assertions. def append? @isolated || @inverted || @distinct end private def append!(assertions) @count += assertions.count if assertions.errored? @errored << assertions elsif assertions.passed? @passed << assertions # if @verbose # @output.write(:indent, :passed, pass_prefix, :reset) # self.print(@output, verbose: false) # @output.puts # end else @failed << assertions # @output.write(:indent, :failed, fail_prefix, :reset) # self.print(@output, verbose: false) # @output.puts end @skipped.concat(assertions.skipped) end # Concatenate the child assertions into this instance. def merge!(assertions) @count += assertions.count @passed.concat(assertions.passed) @failed.concat(assertions.failed) @deferred.concat(assertions.deferred) @skipped.concat(assertions.skipped) @errored.concat(assertions.errored) # if @verbose # @output.write(:indent) # self.print(@output, verbose: false) # @output.puts # end end end end sus-0.35.2/lib/sus/fixtures.rb0000644000004100000410000000023415152126155016223 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022, by Samuel Williams. module Sus # @namespace module Fixtures end end sus-0.35.2/lib/sus/describe.rb0000644000004100000410000000337115152126155016137 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus # Represents a test group that describes a subject (class, module, or feature). module Describe extend Context # @attribute [Object] The subject being described. attr_accessor :subject # Build a new describe block class. # @parameter parent [Class] The parent context class. # @parameter subject [Object] The subject to describe. # @parameter unique [Boolean] Whether the identity should be unique. # @yields {...} Optional block containing nested tests. # @returns [Class] A new describe block class. def self.build(parent, subject, unique: true, &block) base = Class.new(parent) base.singleton_class.prepend(Describe) base.children = Hash.new base.subject = subject base.description = subject.to_s base.identity = Identity.nested(parent.identity, base.description, unique: unique) base.set_temporary_name("#{self}[#{base.description}]") base.define_method(:subject, ->{subject}) if block_given? base.class_exec(&block) end return base end # Print a representation of this describe block. # @parameter output [Output] The output target. def print(output) output.write( "describe ", :describe, self.description, :reset, # " ", self.identity.to_s ) end end module Context # Define a new test group describing a subject. # @parameter subject [Object] The subject to describe (class, module, or feature). # @parameter options [Hash] Additional options. # @yields {...} Optional block containing nested tests. def describe(subject, **options, &block) add Describe.build(self, subject, **options, &block) end end end sus-0.35.2/lib/sus/be_truthy.rb0000644000004100000410000000277415152126155016372 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2023, by Samuel Williams. module Sus # Represents a predicate that checks if the subject is truthy. module BeTruthy # Print a representation of this predicate. # @parameter output [Output] The output target. def self.print(output) output.write("be truthy") end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def self.call(assertions, subject) assertions.nested(self) do |assertions| assertions.assert(subject, self) end end end # Represents a predicate that checks if the subject is falsey. module BeFalsey # Print a representation of this predicate. # @parameter output [Output] The output target. def self.print(output) output.write("be falsey") end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def self.call(assertions, subject) assertions.nested(self) do |assertions| assertions.assert(!subject, self) end end end class Base # Create a predicate that checks if the subject is truthy. # @returns [BeTruthy] A BeTruthy predicate. def be_truthy BeTruthy end # Create a predicate that checks if the subject is falsey. # @returns [BeFalsey] A BeFalsey predicate. def be_falsey BeFalsey end end end sus-0.35.2/lib/sus/it.rb0000644000004100000410000001005315152126155014766 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus # Represents an individual test case. module It # Build a new test case class. # @parameter parent [Class] The parent context class. # @parameter description [String | Nil] Optional description of the test. # @parameter unique [Boolean] Whether the identity should be unique. # @yields {...} Optional block containing the test code. # @returns [Class] A new test case class. def self.build(parent, description = nil, unique: true, &block) base = Class.new(parent) base.extend(It) base.description = description base.identity = Identity.nested(parent.identity, base.description, unique: unique) base.set_temporary_name("#{self}[#{description}]") if block_given? base.define_method(:call, &block) end return base end # @returns [Boolean] Always returns true, as test cases are leaf nodes. def leaf? true end # Print a representation of this test case. # @parameter output [Output] The output target. def print(output) self.superclass.print(output) output.write(" it ", :it, self.description, :reset, " ", :identity, self.identity.to_s, :reset) end # @returns [String] A string representation of this test case. def to_s "it #{description}" end # Execute this test case. # @parameter assertions [Assertions] The assertions instance to use. def call(assertions) assertions.nested(self, identity: self.identity, isolated: true, measure: true) do |assertions| instance = self.new(assertions) instance.around do handle_skip(instance, assertions) end end end # Handle skip logic for the test case. # @parameter instance [Base] The test instance. # @parameter assertions [Assertions] The assertions instance. # @returns [Object] The result of calling the test instance. def handle_skip(instance, assertions) catch(:skip) do return instance.call end end end module Context # Define a new test case. # @parameter description [String] The description of the test. # @parameter options [Hash] Additional options. # @yields {...} The test code. def it(...) add It.build(self, ...) end end class Base # Skip the current test with a reason. # @parameter reason [String] The reason for skipping the test. def skip(reason) @__assertions__.skip(reason) throw :skip, reason end # Skip the test unless a method is defined on the target. # @parameter method [Symbol] The method name to check. # @parameter target [Module, Class] The target class or module to check. def skip_unless_method_defined(method, target) unless target.method_defined?(method) skip "Method #{method} is not defined in #{target}!" end end # Skip the test unless a constant is defined. # @parameter constant [Symbol, String] The constant name to check. # @parameter target [Module, Class] The target class or module to check. def skip_unless_constant_defined(constant, target = Object) unless target.const_defined?(constant) skip "Constant #{constant} is not defined in #{target}!" end end # Skip the test unless the Ruby version meets the minimum requirement. # @parameter version [String] The minimum Ruby version required. def skip_unless_minimum_ruby_version(version) unless RUBY_VERSION >= version skip "Ruby #{version} is required, but running #{RUBY_VERSION}!" end end # Skip the test if the Ruby version exceeds the maximum supported version. # @parameter version [String] The maximum Ruby version supported. def skip_if_maximum_ruby_version(version) if RUBY_VERSION >= version skip "Ruby #{version} is not supported, but running #{RUBY_VERSION}!" end end # Skip the test if the Ruby platform matches the pattern. # @parameter pattern [Regexp] The platform pattern to match against. def skip_if_ruby_platform(pattern) if match = RUBY_PLATFORM.match(pattern) skip "Ruby platform #{match} is not supported!" end end end end sus-0.35.2/lib/sus/clock.rb0000644000004100000410000000424715152126155015455 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2023, by Samuel Williams. module Sus # Represents a clock for measuring elapsed time during test execution. class Clock include Comparable # Create a new clock and start it immediately. # @returns [Clock] A new started clock. def self.start! self.new.tap(&:start!) end # Initialize a new clock. # @parameter duration [Float] Initial duration in seconds. def initialize(duration = 0.0) @duration = duration end # Get the current elapsed duration. # @returns [Float] The elapsed duration in seconds. def duration if @start_time now = Process.clock_gettime(Process::CLOCK_MONOTONIC) @duration += now - @start_time @start_time = now end return @duration end # Compare this clock's duration with another value. # @parameter other [Numeric] The value to compare against. # @returns [Integer] -1, 0, or 1 depending on comparison result. def <=>(other) duration <=> other.to_f end # Convert the duration to a float. # @returns [Float] The duration in seconds. def to_f duration end # Get the duration in milliseconds. # @returns [Float] The duration in milliseconds. def ms duration * 1000.0 end # Get a human-readable string representation of the duration. # @returns [String] A formatted duration string (e.g., "1.5ms", "2.3s"). def to_s duration = self.duration if duration < 0.001 "#{(duration * 1_000_000).round(1)}µs" elsif duration < 1.0 "#{(duration * 1_000).round(1)}ms" else "#{duration.round(1)}s" end end # Reset the clock to a specific duration. # @parameter duration [Float] The duration to reset to. def reset!(duration = 0.0) @duration = duration end # Start the clock. def start! @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) end # Stop the clock and return the final duration. # @returns [Float] The final duration in seconds. def stop! if @start_time now = Process.clock_gettime(Process::CLOCK_MONOTONIC) @duration += now - @start_time @start_time = nil end return duration end end end sus-0.35.2/lib/sus/be_within.rb0000644000004100000410000000521215152126155016323 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus # Represents a predicate that checks if the subject is within a tolerance of a value. class BeWithin # Represents a bounded range check. class Bounded # Initialize a new bounded predicate. # @parameter range [Range] The range to check against. def initialize(range) @range = range end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("be within ", :variable, @range, :reset) end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| assertions.assert(@range.include?(subject)) end end end # Initialize a new BeWithin predicate. # @parameter tolerance [Numeric] The tolerance value. def initialize(tolerance) @tolerance = tolerance end # Create a bounded predicate that checks if the subject is within tolerance of a value. # @parameter value [Numeric] The value to check against. # @returns [Bounded] A new Bounded predicate. def of(value) tolerance = @tolerance.abs return Bounded.new(Range.new(value - tolerance, value + tolerance)) end # Create a bounded predicate that checks if the subject is within a percentage tolerance of a value. # @parameter value [Numeric] The value to check against. # @returns [Bounded] A new Bounded predicate. def percent_of(value) tolerance = Rational(@tolerance, 100) return Bounded.new(Range.new(value - value * tolerance, value + value * tolerance)) end # Print a representation of this predicate. # @parameter output [Output] The output target. def print(output) output.write("be within ", :variable, @tolerance, :reset) end # Evaluate this predicate against a subject. # @parameter assertions [Assertions] The assertions instance to use. # @parameter subject [Object] The subject to evaluate. def call(assertions, subject) assertions.nested(self) do |assertions| assertions.assert(subject < @tolerance, self) end end end class Base # Create a predicate that checks if the subject is within a tolerance or range. # @parameter value [Numeric, Range] The tolerance value or range to check against. # @returns [BeWithin, BeWithin::Bounded] A BeWithin predicate. def be_within(value) case value when Range BeWithin::Bounded.new(value) else BeWithin.new(value) end end end end sus-0.35.2/lib/sus/file.rb0000644000004100000410000001044515152126155015276 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. # Copyright, 2022, by Brad Schrag. require_relative "context" # This has to be done at the top level. It allows us to define constants within the given class while still retaining top-level constant resolution. Sus::TOPLEVEL_CLASS_EVAL = ->(__klass__, __path__){__klass__.class_eval(::File.read(__path__), __path__)} # This is a hack to allow us to get the line number of a syntax error. unless SyntaxError.method_defined?(:lineno) # Extension to SyntaxError to extract line numbers from error messages. class SyntaxError # Extract the line number from the error message. # @returns [Integer, nil] The line number if found in the message. def lineno if message =~ /:(\d+):/ $1.to_i end end end end module Sus # Represents a test file that can be loaded and executed. module File extend Context # Load a test file. # @parameter path [String] The path to the test file. # @returns [Class] A test class representing the file. def self.[] path self.build(Sus.base, path) end # Called when this module is extended. # @parameter base [Class] The class being extended. def self.extended(base) base.children = Hash.new end # Build a test class from a file path. # @parameter parent [Class] The parent context class. # @parameter path [String] The path to the test file. # @returns [Class] A test class representing the file. def self.build(parent, path) base = Class.new(parent) base.extend(File) base.description = path base.identity = Identity.file(parent.identity, path) base.set_temporary_name("#{self}[#{path}]") begin TOPLEVEL_CLASS_EVAL.call(base, path) rescue StandardError, LoadError, SyntaxError => error # We add this as a child of the base class so that it is included in the tree under the file rather than completely replacing it, which can be confusing: base.add FileLoadError.build(self, path, error) end return base end # Print a representation of this file context. # @parameter output [Output] The output target. def print(output) output.write("file ", :path, self.identity, :reset) end end # Represents an error that occurred while loading a test file. class FileLoadError # Build a new FileLoadError. # @parameter parent [Object] The parent context. # @parameter path [String] The path to the file that failed to load. # @parameter error [Exception] The error that occurred. # @returns [FileLoadError] A new FileLoadError instance. def self.build(parent, path, error) identity = Identity.file(parent.identity, path) # This is a mess. if error.is_a?(SyntaxError) and error.path == path identity = identity.with_line(error.lineno) else identity = identity.scoped(error.backtrace_locations) end self.new(identity, path, error) end # Initialize a new FileLoadError. # @parameter identity [Identity] The identity where the error occurred. # @parameter path [String] The path to the file. # @parameter error [Exception] The error that occurred. def initialize(identity, path, error) @identity = identity @path = path @error = error end # @attribute [Identity] The identity where the error occurred. attr :identity # @attribute [Exception] The error that occurred. attr :error # @returns [Boolean] Always returns true, as errors are leaf nodes. def leaf? true end # An empty hash used for children. EMPTY = Hash.new.freeze # @returns [Hash] Always returns an empty hash. def children EMPTY end # @returns [String] The file path. def description @path end # Print a representation of this error. # @parameter output [Output] The output target. def print(output) output.write("file ", :path, @identity) end # Execute this error, recording it in assertions. # @parameter assertions [Assertions] The assertions instance. def call(assertions) assertions.nested(self, identity: @identity, isolated: true) do |assertions| assertions.error!(@error) end end end private_constant :FileLoadError module Context # Load a test file as a child context. # @parameter path [String] The path to the test file. def file(path) add File.build(self, path) end end end sus-0.35.2/lib/sus/context.rb0000644000004100000410000000760215152126155016044 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "assertions" require_relative "identity" module Sus # Represents a test context that can contain nested tests and other contexts. module Context # @attribute [Identity, nil] The identity of this context. attr_accessor :identity # @attribute [String, nil] The description of this context. attr_accessor :description # @attribute [Hash] The child contexts and tests. attr_accessor :children # Called when this module is extended. # @parameter base [Class] The class being extended. def self.extended(base) base.children = Hash.new end unless respond_to?(:set_temporary_name) # Set a temporary name for this context. # @parameter name [String] The temporary name. def set_temporary_name(name) # No-op. end # @returns [String] A string representation of this context. def to_s (self.description || self.name).to_s end # @returns [String] An inspect representation of this context. def inspect if description = self.description "\#<#{self.name || "Context"} #{self.description}>" else self.name end end end # Add a child context or test to this context. # @parameter child [Object] The child to add. def add(child) @children[child.identity] = child end # @returns [Boolean] Whether this context has no children. def empty? @children.nil? || @children.empty? end # @returns [Boolean] Always returns false, as contexts are not leaf nodes. def leaf? false end # Print a representation of this context. # @parameter output [Output] The output target. def print(output) output.write("context ", :context, self.description, :reset) end # @returns [String] The full name of this context. def full_name output = Output::Buffered.new print(output) return output.string end # Execute all child contexts and tests. # @parameter assertions [Assertions] The assertions instance to use. def call(assertions) return if self.empty? assertions.nested(self) do |assertions| self.children.each do |identity, child| child.call(assertions) end end end # Iterate over all leaf nodes (test cases) in this context. # @yields {|test| ...} Each test case. def each(&block) self.children.each do |identity, child| if child.leaf? yield child else child.each(&block) end end end # Include a before hook to the context class, that invokes the given block before running each test. # # Before hooks are usually invoked in the order they are defined, i.e. the first defined hook is invoked first. # # @yields {...} The block to execute before each test. def before(&hook) wrapper = Module.new wrapper.define_method(:before) do super() instance_exec(&hook) end self.include(wrapper) end # Include an after hook to the context class, that invokes the given block after running each test. # # After hooks are usually invoked in the reverse order they are defined, i.e. the last defined hook is invoked first. # # @yields {|error| ...} The block to execute after each test. An `error` argument is passed if the test failed with an exception. def after(&hook) wrapper = Module.new wrapper.define_method(:after) do |error| instance_exec(error, &hook) rescue => error raise ensure super(error) end self.include(wrapper) end # Add an around hook to the context class. # # Around hooks are called in the reverse order they are defined. # # The top level `around` implementation invokes before and after hooks. # # @yields {|&block| ...} The block to execute around each test. def around(&block) wrapper = Module.new wrapper.define_method(:around, &block) self.include(wrapper) end end end sus-0.35.2/lib/sus/version.rb0000644000004100000410000000023615152126155016041 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2025, by Samuel Williams. # @namespace module Sus VERSION = "0.35.2" end sus-0.35.2/lib/sus/tree.rb0000644000004100000410000000216415152126155015315 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023, by Samuel Williams. module Sus # Represents a tree structure of test contexts. class Tree # Initialize a new Tree. # @parameter context [Object] The root context. def initialize(context) @context = context end # Traverse the tree, yielding each context. # @parameter current [Object] The current context (defaults to root). # @yields {|context| ...} Each context in the tree. # @returns [Hash] A hash representation of the tree. def traverse(current = @context, &block) node = {} node[:self] = yield(current) if children = current.children # and children.any? node[:children] = children.values.map do |context| self.traverse(context, &block) end end return node end # Convert the tree to JSON. # @parameter options [Hash, nil] Options to pass to JSON.generate. # @returns [String] A JSON representation of the tree. def to_json(options = nil) traverse do |context| [context.identity.to_s, context.description.to_s, context.leaf?] end.to_json(options) end end end sus-0.35.2/lib/sus/output/0000755000004100000410000000000015152126155015366 5ustar www-datawww-datasus-0.35.2/lib/sus/output/null.rb0000644000004100000410000000235015152126155016665 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2025, by Samuel Williams. require_relative "messages" module Sus module Output # Represents a null output handler that discards all output. class Null include Messages # Initialize a new Null output handler. def initialize end # Create a buffered output handler. # @returns [Buffered] A new Buffered instance. def buffered Buffered.new(nil) end # @attribute [Hash, nil] Optional options (unused). attr :options # Append chunks from a buffer (no-op). # @parameter buffer [Buffered] The buffer to append from. def append(buffer) end # Increase indentation (no-op). def indent end # Decrease indentation (no-op). def outdent end # Execute a block with indentation (no-op, just yields). # @yields {...} The block to execute. def indented yield end # Write output (no-op). # @parameter arguments [Array] The arguments to write. def write(*arguments) # Do nothing. end # Write output followed by a newline (no-op). # @parameter arguments [Array] The arguments to write. def puts(*arguments) # Do nothing. end end end end sus-0.35.2/lib/sus/output/messages.rb0000644000004100000410000000621015152126155017521 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. module Sus module Output # Provides message formatting methods for output handlers. module Messages # The prefix for passed assertions. PASSED_PREFIX = [:passed, "✓ "].freeze # The prefix for failed assertions. FAILED_PREFIX = [:failed, "✗ "].freeze # Get the prefix for a passed assertion based on orientation. # @parameter orientation [Boolean] The orientation of the assertions. # @returns [Array] The prefix array. def pass_prefix(orientation) if orientation PASSED_PREFIX else FAILED_PREFIX end end # Get the prefix for a failed assertion based on orientation. # @parameter orientation [Boolean] The orientation of the assertions. # @returns [Array] The prefix array. def fail_prefix(orientation) if orientation FAILED_PREFIX else PASSED_PREFIX end end # Print an assertion result. # If the orientation is true, and the test passed, then it is a successful outcome. # If the orientation is false, and the test failed, then it is a successful outcome. # Otherwise, it is a failed outcome. # # @parameter condition [Boolean] The result of the test. # @parameter orientation [Boolean] The orientation of the assertions. # @parameter message [String] The message to display. # @parameter backtrace [Backtrace] The backtrace to display. def assert(condition, orientation, message, backtrace) if condition self.puts(:indent, *pass_prefix(orientation), message, backtrace) else self.puts(:indent, *fail_prefix(orientation), message, backtrace) end end # @returns [String] The prefix for skipped tests. def skip_prefix "⏸ " end # Print a skip message. # @parameter reason [String] The reason for skipping. # @parameter identity [Identity, nil] The identity where the skip occurred. def skip(reason, identity) self.puts(:indent, :skipped, skip_prefix, reason) end # @returns [Array] The prefix for error messages. def error_prefix [:errored, "⚠ "] end # Print an error message. # @parameter error [Exception] The error to display. # @parameter identity [Identity, nil] The identity where the error occurred. # @parameter prefix [Array] Optional prefix to use. def error(error, identity, prefix = error_prefix) lines = error.message.split(/\r?\n/) self.puts(:indent, *prefix, error.class, ": ", lines.shift) lines.each do |line| self.puts(:indent, line) end self.write(Output::Backtrace.for(error, identity)) if cause = error.cause self.error(cause, identity, ["Caused by ", :errored]) end end # @returns [String] The prefix for informational messages. def inform_prefix "ℹ " end # Print an informational message. # @parameter message [String] The message to display. # @parameter identity [Identity, nil] The identity where the message was generated. def inform(message, identity) self.puts(:indent, :inform, inform_prefix, message) end end end end sus-0.35.2/lib/sus/output/text.rb0000644000004100000410000000671415152126155016707 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "messages" require_relative "buffered" module Sus module Output # Represents a plain text output handler without color support. class Text include Messages # Initialize a new Text output handler. # @parameter io [IO] The IO object to write to. def initialize(io) @io = io @styles = {reset: self.reset} @indent = String.new @styles[:indent] = @indent end # @attribute [Hash] The style definitions. attr :styles # Create a buffered output handler. # @returns [Buffered] A new Buffered instance. def buffered Buffered.new(self) end # Append and replay chunks from a buffer. # @parameter buffer [Buffered] The buffer to append from. def append(buffer) buffer.each do |operation| self.public_send(*operation) end end # @attribute [IO] The IO object to write to. attr :io # The indentation string. INDENTATION = "\t" # Increase indentation level. def indent @indent << INDENTATION end # Decrease indentation level. def outdent @indent.slice!(INDENTATION) end # Execute a block with increased indentation. # @yields {...} The block to execute. def indented self.indent yield ensure self.outdent end # @returns [Boolean] Whether the IO is interactive (a TTY). def interactive? @io.tty? end # Get a style by key. # @parameter key [Symbol] The style key. # @returns [String] The style value. def [] key @styles[key] end # Set a style by key. # @parameter key [Symbol] The style key. # @parameter value [String] The style value. def []= key, value @styles[key] = value end # @returns [Array(Integer)] The terminal size [height, width] (defaults to [24, 80]). def size [24, 80] end # @returns [Integer] The terminal width (defaults to 80). def width size.last end # @returns [Boolean] Always returns false, as Text output doesn't support colors. def colors? false end # Create a style string (no-op for Text output). # @parameter foreground [Symbol, nil] The foreground color. # @parameter background [Symbol, nil] The background color. # @parameter attributes [Array] Additional style attributes. # @returns [String] An empty string. def style(foreground, background = nil, *attributes) end # @returns [String] An empty string (no reset needed for plain text). def reset end # Print out the given arguments. # When the argument is a symbol, look up the style and inject it into the io stream. # When the argument is a proc/lambda, call it with self as the argument. # When the argument is anything else, write it directly to the io. # @parameter arguments [Array] The arguments to write. def write(*arguments) arguments.each do |argument| case argument when Symbol @io.write(self[argument]) when Proc argument.call(self) else if argument.respond_to?(:print) argument.print(self) else @io.write(argument) end end end end # Print out the arguments as per {#write}, followed by the reset sequence and a newline. # @parameter arguments [Array] The arguments to write. def puts(*arguments) write(*arguments) @io.puts(self.reset) end end end end sus-0.35.2/lib/sus/output/progress.rb0000644000004100000410000000765615152126155017575 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "bar" require_relative "status" require_relative "lines" module Sus module Output # Represents a progress tracker for test execution. class Progress # Get the current monotonic time. # @returns [Float] The current time in seconds. def self.now ::Process.clock_gettime(Process::CLOCK_MONOTONIC) end # Initialize a new Progress tracker. # @parameter output [Output] The output handler. # @parameter total [Integer] The total number of items to track. # @parameter minimum_output_duration [Float] Minimum duration before showing output (unused). def initialize(output, total = 0, minimum_output_duration: 1.0) @output = output @subject = subject @start_time = Progress.now if @output.interactive? @bar = Bar.new @lines = Lines.new(@output) @lines[0] = @bar end @current = 0 @total = total end # @attribute [Object, nil] The subject being tracked. attr :subject # @attribute [Integer] The current progress value. attr :current # @attribute [Integer] The total value. attr :total # @returns [Float] The elapsed duration in seconds. def duration Progress.now - @start_time end # @returns [Float] The progress as a fraction (0.0 to 1.0). def progress @current.to_f / @total.to_f end # @returns [Integer] The remaining items to process. def remaining @total - @current end # @returns [Float, nil] The average duration per item, or nil if no items completed. def average_duration if @current > 0 duration / @current end end # @returns [Float, nil] The estimated remaining time, or nil if cannot be calculated. def estimated_remaining_time if average_duration = self.average_duration average_duration * remaining end end # Increase the amount of work done. # @parameter amount [Integer] The amount to increment by. # @returns [Progress] Returns self for method chaining. def increment(amount = 1) @current += amount @bar&.update(@current, @total, self.to_s) @lines&.redraw(0) return self end # Increase the total size of the progress. # @parameter amount [Integer] The amount to expand by. # @returns [Progress] Returns self for method chaining. def expand(amount = 1) @total += amount @bar&.update(@current, @total, self.to_s) @lines&.redraw(0) return self end # Report the status of a specific item. # @parameter index [Integer] The index of the item. # @parameter context [Object] The context to display. # @parameter state [Symbol] The state (:free or :busy). # @returns [Progress] Returns self for method chaining. def report(index, context, state) @lines&.[]=(index+1, Status.new(state, context)) return self end # Clear the progress display. def clear @lines&.clear end # @returns [String] A string representation of the progress. def to_s if estimated_remaining_time = self.estimated_remaining_time "#{@current}/#{@total} completed in #{formatted_duration(self.duration)}, #{formatted_duration(estimated_remaining_time)} remaining" else "#{@current}/#{@total} completed" end end private def formatted_duration(duration) seconds = duration.floor if seconds < 60.0 return "#{seconds}s" end minutes = (duration / 60.0).floor seconds = (seconds - (minutes * 60)).round if minutes < 60.0 return "#{minutes}m#{seconds}s" end hours = (minutes / 60.0).floor minutes = (minutes - (hours * 60)).round if hours < 24.0 return "#{hours}h#{minutes}m" end days = (hours / 24.0).floor hours = (hours - (days * 24)).round return "#{days}d#{hours}h" end end end end sus-0.35.2/lib/sus/output/structured.rb0000644000004100000410000000303015152126155020113 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2024, by Samuel Williams. require_relative "null" module Sus module Output # Represents a structured JSON output handler for machine-readable output. class Structured < Null # Create a buffered structured output handler. # @parameter io [IO] The IO object to write to. # @parameter identity [Identity, nil] Optional identity. # @returns [Buffered] A new Buffered instance wrapping a Structured handler. def self.buffered(...) Buffered.new(self.new(...)) end # Initialize a new Structured output handler. # @parameter io [IO] The IO object to write to. # @parameter identity [Identity, nil] Optional identity. def initialize(io, identity = nil) @io = io @identity = identity end # Output a skip message as JSON. # @parameter reason [String] The reason for skipping. # @parameter identity [Identity, nil] The identity where the skip occurred. def skip(reason, identity) inform(reason.to_s, identity) end # Output an informational message as JSON. # @parameter message [String, Object] The message to output. # @parameter identity [Identity, nil] The identity where the message was generated. def inform(message, identity) unless message.is_a?(String) message = message.inspect end @io.puts(JSON.generate({ inform: @identity, message: { text: message, location: identity&.to_location, } })) @io.flush end end end end sus-0.35.2/lib/sus/output/buffered.rb0000644000004100000410000000612715152126155017503 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require "io/console" require "stringio" module Sus module Output # Represents a buffered output handler that stores output operations for later replay. class Buffered # Initialize a new Buffered output handler. # @parameter tee [Output, nil] Optional output handler to tee output to. def initialize(tee = nil) @chunks = Array.new @tee = tee end # @attribute [Array] The stored output chunks. attr :chunks # @attribute [Output, nil] The output handler to tee to. attr :tee # @returns [String] A string representation of this buffered output. def inspect if @tee "\#<#{self.class.name} #{@chunks.size} chunks -> #{@tee.class}>" else "\#<#{self.class.name} #{@chunks.size} chunks>" end end # Create a nested buffered output handler. # @returns [Buffered] A new Buffered instance that tees to this one. def buffered self.class.new(self) end # Iterate over stored chunks. # @yields {|chunk| ...} Each stored chunk. def each(&block) @chunks.each(&block) end # Append chunks from another buffer. # @parameter buffer [Buffered] The buffer to append from. def append(buffer) @chunks.concat(buffer.chunks) @tee&.append(buffer) end # @returns [String] The buffered output as a string. def string io = StringIO.new Text.new(io).append(@chunks) return io.string end # The indent operation marker. INDENT = [:indent].freeze # Increase indentation level. def indent @chunks << INDENT @tee&.indent end # The outdent operation marker. OUTDENT = [:outdent].freeze # Decrease indentation level. def outdent @chunks << OUTDENT @tee&.outdent end # Execute a block with increased indentation. # @yields {...} The block to execute. def indented self.indent yield ensure self.outdent end # Write output. # @parameter arguments [Array] The arguments to write. def write(*arguments) @chunks << [:write, *arguments] @tee&.write(*arguments) end # Write output followed by a newline. # @parameter arguments [Array] The arguments to write. def puts(*arguments) @chunks << [:puts, *arguments] @tee&.puts(*arguments) end # Record an assertion. # @parameter arguments [Array] The assertion arguments. def assert(*arguments) @chunks << [:assert, *arguments] @tee&.assert(*arguments) end # Record a skip. # @parameter arguments [Array] The skip arguments. def skip(*arguments) @chunks << [:skip, *arguments] @tee&.skip(*arguments) end # Record an error. # @parameter arguments [Array] The error arguments. def error(*arguments) @chunks << [:error, *arguments] @tee&.error(*arguments) end # Record an informational message. # @parameter arguments [Array] The message arguments. def inform(*arguments) @chunks << [:inform, *arguments] @tee&.inform(*arguments) end end end end sus-0.35.2/lib/sus/output/backtrace.rb0000644000004100000410000000726515152126155017644 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2024, by Samuel Williams. module Sus module Output # Represents a backtrace for displaying error locations. class Backtrace # Create a backtrace from the first caller location. # @parameter identity [Identity, nil] Optional identity to filter by path. # @returns [Backtrace] A new Backtrace instance. def self.first(identity = nil) # This implementation could be a little more efficient. self.new(caller_locations(1), identity&.path, 1) end # Create a backtrace from an exception. # @parameter exception [Exception] The exception to extract the backtrace from. # @parameter identity [Identity, nil] Optional identity to filter by path. # @returns [Backtrace] A new Backtrace instance. def self.for(exception, identity = nil) # I've disabled the root filter here, because partial backtraces are not very useful. # We might want to do something to improve presentation of the backtrace based on the root instead. self.new(extract_stack(exception), identity&.path) end # Represents a location in a backtrace. Location = Struct.new(:path, :lineno, :label) # Extract the stack trace from an exception. # @parameter exception [Exception] The exception to extract from. # @returns [Array] An array of location objects. def self.extract_stack(exception) if stack = exception.backtrace_locations return stack elsif stack = exception.backtrace return stack.map do |line| Location.new(*line.split(":", 3)) end else [] end end # Initialize a new Backtrace. # @parameter stack [Array] The stack trace locations. # @parameter root [String, nil] Optional root path to filter by. # @parameter limit [Integer, nil] Optional limit on the number of frames. def initialize(stack, root = nil, limit = nil) @stack = stack @root = root @limit = limit end # @attribute [Array] The stack trace locations. attr :stack # @attribute [String, nil] The root path to filter by. attr :root # @attribute [Integer, nil] The limit on the number of frames. attr :limit # Filter the backtrace by root path and limit. # @parameter root [String, nil] Optional root path to filter by. # @parameter limit [Integer, nil] Optional limit on the number of frames. # @returns [Array, Enumerator] The filtered stack trace. def filter(root: @root, limit: @limit) if root if limit return @stack.lazy.select do |frame| frame.path.start_with?(root) end.first(limit) else return up_to_and_matching(@stack) do |frame| frame.path.start_with?(root) end end elsif limit return @stack.first(limit) else return @stack end end # Print the backtrace to the output. # @parameter output [Output] The output handler. def print(output) if @limit == 1 filter.each do |frame| output.write " ", :path, frame.path, :line, ":", frame.lineno end else output.indented do filter.each do |frame| output.puts :indent, :path, frame.path, :line, ":", frame.lineno, :reset, " ", frame.label end end end end # Select items up to and matching a condition. # @parameter things [Enumerable] The items to filter. # @yields {|thing| ...} The condition to match. # @returns [Array] The filtered items. private def up_to_and_matching(things, &block) preface = true things.select do |thing| if preface if yield(thing) preface = false end true elsif yield(thing) true else false end end end end end end sus-0.35.2/lib/sus/output/status.rb0000644000004100000410000000301715152126155017237 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus module Output # Represents a status indicator for test execution. class Status # Register status styling with an output handler. # @parameter output [Output] The output handler to register with. def self.register(output) output[:free] ||= output.style(:blue) output[:busy] ||= output.style(:orange) end # Initialize a new Status indicator. # @parameter state [Symbol] The state (:free or :busy). # @parameter context [Object, nil] Optional context to display. def initialize(state = :free, context = nil) @state = state @context = context end # Status indicators for different states. INDICATORS = { busy: ["◑", "◒", "◐", "◓"], free: ["◌"] } # Update the status. # @parameter state [Symbol] The new state. # @parameter context [Object, nil] Optional new context. def update(state, context = nil) @state = state @context = context end # @returns [String] The current indicator character (animated for busy state). def indicator if indicators = INDICATORS[@state] return indicators[(Time.now.to_f * 10) % indicators.size] end return " " end # Print the status to the output. # @parameter output [Output] The output handler. def print(output) output.write( @state, self.indicator, " " ) output.write(@context) output.puts end end end end sus-0.35.2/lib/sus/output/xterm.rb0000644000004100000410000000323215152126155017052 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require "io/console" require_relative "text" module Sus module Output # Represents an XTerm-compatible output handler with color and style support. class XTerm < Text # Color codes for ANSI terminal colors. COLORS = { black: 0, red: 1, green: 2, yellow: 3, blue: 4, magenta: 5, cyan: 6, white: 7, default: 9, } # Style attribute codes for ANSI terminal attributes. ATTRIBUTES = { normal: 0, bold: 1, bright: 1, faint: 2, italic: 3, underline: 4, blink: 5, reverse: 7, hidden: 8, } # @returns [Boolean] Always returns true, as XTerm output supports colors. def colors? true end # @returns [Array(Integer)] The terminal size [height, width]. def size @io.winsize end # Create an ANSI escape sequence for styling. # @parameter foreground [Symbol, nil] The foreground color name. # @parameter background [Symbol, nil] The background color name. # @parameter attributes [Array] Additional style attributes. # @returns [String] An ANSI escape sequence. def style(foreground, background = nil, *attributes) tokens = [] if foreground tokens << 30 + COLORS.fetch(foreground) end if background tokens << 40 + COLORS.fetch(background) end attributes.each do |attribute| tokens << ATTRIBUTES.fetch(attribute){attribute.to_i} end return "\e[#{tokens.join(';')}m" end # @returns [String] The ANSI reset sequence. def reset "\e[0m" end end end end sus-0.35.2/lib/sus/output/lines.rb0000644000004100000410000000355715152126155017037 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require "io/console" module Sus module Output # Represents a line buffer for managing multiple lines of output on a terminal. class Lines # Initialize a new Lines buffer. # @parameter output [Output] The output handler to write to. def initialize(output) @output = output @lines = [] @current_count = 0 end # @returns [Integer] The height of the terminal. def height @output.size.first end # Set a line at the given index. # @parameter index [Integer] The line index. # @parameter line [Object] The line content (should respond to #print). def []= index, line @lines[index] = line redraw(index) end # Clear all lines. def clear @lines.clear write end # Redraw a specific line or all lines. # @parameter index [Integer] The line index to redraw. def redraw(index) if index < @current_count update(index, @lines[index]) else write end end private def soft_wrap @output.write("\e[?7l") yield ensure @output.write("\e[?7h") end def origin if @current_count > 0 @output.write("\e[#{@current_count}F\e[J") end @current_count = 0 end def write origin height = self.height soft_wrap do @lines.each do |line| break if (@current_count+1) >= height if line line.print(@output) else @output.puts end @current_count += 1 end end end def update(index, line) offset = @current_count - index @output.write("\e[#{offset}F\e[K") soft_wrap do line.print(@output) end if offset > 1 @output.write("\e[#{offset-1}E") end end end end end sus-0.35.2/lib/sus/output/bar.rb0000644000004100000410000000506615152126155016466 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2022, by Samuel Williams. module Sus module Output # Represents a progress bar for displaying test execution progress. class Bar # Unicode block characters for drawing the progress bar. BLOCK = [ " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", ] # Initialize a new progress bar. # @parameter current [Integer] The current progress value. # @parameter total [Integer] The total value. # @parameter message [String, nil] Optional message to display. def initialize(current = 0, total = 0, message = nil) @maximum_message_width = 0 @current = current @total = total @message = message end # Update the progress bar values. # @parameter current [Integer] The current progress value. # @parameter total [Integer] The total value. # @parameter message [String, nil] Optional message to display. def update(current, total, message) @current = current @total = total @message = message end # Register progress bar styling with an output handler. # @parameter output [Output] The output handler to register with. def self.register(output) output[:progress_bar] ||= output.style(:blue, :white) end # The minimum width for the progress bar. MINIMUM_WIDTH = 8 # The suffix to append to messages. MESSAGE_SUFFIX = ": " # Print the progress bar to the output. # @parameter output [Output] The output handler. def print(output) width = output.width unless @total.zero? value = @current.to_f / @total.to_f else value = 0.0 end if @message message = @message + MESSAGE_SUFFIX if message.size > @maximum_message_width @maximum_message_width = message.size end if @maximum_message_width < (width - MINIMUM_WIDTH) width -= @maximum_message_width message = message.rjust(@maximum_message_width) else @maximum_message_width = 0 message = nil end end if message output.write(message) end output.write( :progress_bar, draw(value, width), :reset, ) output.puts end private def draw(value, width) blocks = width * value full_blocks = blocks.floor partial_block = ((blocks - full_blocks) * BLOCK.size).floor if partial_block.zero? BLOCK.last * full_blocks else "#{BLOCK.last * full_blocks}#{BLOCK[partial_block]}" end.ljust(width) end end end end sus-0.35.2/lib/sus/shared.rb0000644000004100000410000000257415152126155015631 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "context" module Sus # Represents a shared test context that can be reused across multiple test files. module Shared # @attribute [String] The name of the shared context. attr_accessor :name # @attribute [Proc] The block containing the shared test code. attr_accessor :block # Build a new Shared context. # @parameter name [String] The name of the shared context. # @parameter block [Proc] The block containing the shared test code. # @returns [Module] A new Shared module. def self.build(name, block) base = Module.new base.extend(Shared) base.name = name base.block = block return base end # Called when this module is included in a test class. # @parameter base [Class] The class including this module. def included(base) base.class_exec(&self.block) end # Called when this module is prepended to a test class. # @parameter base [Class] The class prepending this module. def prepended(base) base.class_exec(&self.block) end end # Create a new shared test context. # @parameter name [String] The name of the shared context. # @yields {...} The block containing the shared test code. # @returns [Shared] A new Shared module. def self.Shared(name, &block) Shared.build(name, block) end end sus-0.35.2/lib/sus/registry.rb0000644000004100000410000000424015152126155016223 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. # Copyright, 2022, by Brad Schrag. require_relative "base" require_relative "file" require_relative "describe" require_relative "with" require_relative "it" require_relative "shared" require_relative "it_behaves_like" require_relative "include_context" require_relative "let" module Sus # Represents a registry of test files and contexts. class Registry # The glob pattern used to find Ruby files in directories. DIRECTORY_GLOB = "**/*.rb" # Initialize a new registry. # @parameter options [Hash] Options to pass to the base context. def initialize(**options) @base = Sus.base(self, **options) @loaded = {} end # @attribute [Class] The base test context class. attr :base # Print a representation of this registry. # @parameter output [Output] The output target. def print(output) output.write("Test Registry") end # @returns [String] A string representation of this registry. def to_s @base&.identity&.to_s || self.class.name end # Load a test file or directory. # @parameter path [String] The path to load (file or directory). def load(path) if ::File.directory?(path) load_directory(path) else load_file(path) end end # Load a single test file. # @parameter path [String] The path to the test file. private def load_file(path) @loaded[path] ||= @base.file(path) end # Load all Ruby files in a directory. # @parameter path [String] The directory path. private def load_directory(path) ::Dir.glob(::File.join(path, DIRECTORY_GLOB), &self.method(:load_file)) end # Execute all tests in the registry. # @parameter assertions [Assertions] Optional assertions instance to use. # @returns [Assertions] The assertions instance with results. def call(assertions = Assertions.default) @base.call(assertions) return assertions end # Iterate over all test cases in the registry. # @yields {|test| ...} Each test case. def each(...) @base.each(...) end # @returns [Hash] The child contexts and tests. def children @base.children end end end sus-0.35.2/lib/sus/identity.rb0000644000004100000410000001223715152126155016211 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. module Sus # Represents a unique identity for a test or context, used for identification and location tracking. class Identity # Create an identity for a file. # @parameter parent [Identity, nil] The parent identity. # @parameter path [String] The file path. # @parameter name [String] The name (defaults to path). # @parameter options [Hash] Additional options. # @returns [Identity] A new Identity instance. def self.file(parent, path, name = path, **options) self.new(path, name, nil, nil, **options) end # Create a nested identity. # @parameter parent [Identity, nil] The parent identity. # @parameter name [String] The name of this identity. # @parameter location [Thread::Backtrace::Location, nil] Optional location (defaults to caller location). # @parameter options [Hash] Additional options. # @returns [Identity] A new Identity instance. def self.nested(parent, name, location = nil, **options) location ||= caller_locations(3...4).first self.new(location.path, name, location.lineno, parent, **options) end # Create an identity for the current location. # @returns [Identity] A new Identity instance for the current caller location. def self.current self.nested(nil, nil, caller_locations(1...2).first) end # Initialize a new Identity. # @parameter path [String] The file path. # @parameter name [String, nil] Optional name. # @parameter line [Integer, nil] Optional line number. # @parameter parent [Identity, nil] Optional parent identity. # @parameter unique [Boolean, Symbol] Whether this identity is unique or needs a unique key/line number suffix. def initialize(path, name = nil, line = nil, parent = nil, unique: true) @path = path @name = name @line = line @parent = parent @unique = unique @key = nil end # Create a new identity with a different line number. # @parameter line [Integer] The line number. # @returns [Identity] A new Identity instance. def with_line(line) self.class.new(@path, @name, line, @parent, unique: @unique) end # @attribute [String] The file path. attr :path # @attribute [String, nil] The name. attr :name # @attribute [Integer, nil] The line number. attr :line # @attribute [Identity, nil] The parent identity. attr :parent # @attribute [Boolean, Symbol] Whether this identity is unique. attr :unique # @returns [String] A string representation of this identity (the key). def to_s self.key end # @returns [Hash] A hash containing the path and line number. def to_location { path: ::File.expand_path(@path), line: @line, } end # @returns [String] An inspect representation of this identity. def inspect "\#<#{self.class} #{self.to_s}>" end # Check if this identity matches another. # @parameter other [Identity] The identity to match against. # @returns [Boolean] Whether the identities match. def match?(other) if path = other.path return false unless path === @path end if name = other.name return false unless name === @name end if line = other.line return false unless line === @line end end # Iterate over this identity and all its parents. # @yields {|identity| ...} Each identity in the chain. def each(&block) @parent&.each(&block) yield self end # @returns [String] A unique key for this identity. def key unless @key key = Array.new # For a specific leaf node, the last part is not unique, i.e. it must be identified explicitly. append_unique_key(key, @unique == true ? false : @unique) @key = key.join(":") end return @key end # Given a set of locations, find the first one which matches this identity and return a new identity with the updated line number. This can be used to extract a location from a backtrace. # @parameter locations [Array(Thread::Backtrace::Location), nil] Optional locations to search (defaults to caller locations). # @returns [Identity] A new identity with updated line number if a match is found, otherwise returns self. def scoped(locations = nil) if locations # This code path is normally taken if we've got an exception with a backtrace: locations.each do |location| if location.path == @path return self.with_line(location.lineno) end end else # In theory this should be a bit faster: each_caller_location do |location| if location.path == @path return self.with_line(location.lineno) end end end return self end protected if Thread.respond_to?(:each_caller_location) def each_caller_location(&block) Thread.each_caller_location(&block) end else def each_caller_location(&block) caller_locations(1).each(&block) end end def append_unique_key(key, unique = @unique) if @parent @parent.append_unique_key(key) else key << @path end if unique == true # No key is needed because this identity is unique. else if unique key << unique elsif @line key << @line end end end end end sus-0.35.2/lib/sus.rb0000644000004100000410000000111015152126155014344 0ustar www-datawww-data# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2024, by Samuel Williams. require_relative "sus/version" require_relative "sus/config" require_relative "sus/registry" require_relative "sus/assertions" require_relative "sus/tree" require_relative "sus/expect" require_relative "sus/be" require_relative "sus/be_truthy" require_relative "sus/be_within" require_relative "sus/mock" require_relative "sus/receive" require_relative "sus/raise_exception" require_relative "sus/have_duration" require_relative "sus/have" require_relative "sus/filter" sus-0.35.2/sus.gemspec0000644000004100000410000001105015152126155014622 0ustar www-datawww-data######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: sus 0.35.2 ruby lib Gem::Specification.new do |s| s.name = "sus".freeze s.version = "0.35.2".freeze s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.metadata = { "documentation_uri" => "https://socketry.github.io/sus/", "funding_uri" => "https://github.com/sponsors/ioquatix/", "source_code_uri" => "https://github.com/socketry/sus.git" } if s.respond_to? :metadata= s.require_paths = ["lib".freeze] s.authors = ["Samuel Williams".freeze, "Brad Schrag".freeze] s.cert_chain = ["-----BEGIN CERTIFICATE-----\nMIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11\nZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK\nCZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz\nMjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd\nMBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj\nbzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB\nigKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2\n9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW\nsGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE\ne5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN\nXibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss\nRZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn\ntUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM\nzp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW\nxm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O\nBBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs\naWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs\naWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE\ncBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl\nxCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/\nc1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp\n8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws\nJkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP\neX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt\nQ2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8\nvoD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=\n-----END CERTIFICATE-----\n".freeze] s.date = "1980-01-02" s.executables = ["sus".freeze, "sus-host".freeze, "sus-parallel".freeze, "sus-tree".freeze] s.files = ["bin/sus".freeze, "bin/sus-host".freeze, "bin/sus-parallel".freeze, "bin/sus-tree".freeze, "context/getting-started.md".freeze, "context/index.yaml".freeze, "context/mocking.md".freeze, "context/shared-contexts.md".freeze, "lib/sus.rb".freeze, "lib/sus/assertions.rb".freeze, "lib/sus/base.rb".freeze, "lib/sus/be.rb".freeze, "lib/sus/be_truthy.rb".freeze, "lib/sus/be_within.rb".freeze, "lib/sus/clock.rb".freeze, "lib/sus/config.rb".freeze, "lib/sus/context.rb".freeze, "lib/sus/describe.rb".freeze, "lib/sus/expect.rb".freeze, "lib/sus/file.rb".freeze, "lib/sus/filter.rb".freeze, "lib/sus/fixtures.rb".freeze, "lib/sus/fixtures/temporary_directory_context.rb".freeze, "lib/sus/have.rb".freeze, "lib/sus/have/all.rb".freeze, "lib/sus/have/any.rb".freeze, "lib/sus/have_duration.rb".freeze, "lib/sus/identity.rb".freeze, "lib/sus/include_context.rb".freeze, "lib/sus/integrations.rb".freeze, "lib/sus/it.rb".freeze, "lib/sus/it_behaves_like.rb".freeze, "lib/sus/let.rb".freeze, "lib/sus/mock.rb".freeze, "lib/sus/output.rb".freeze, "lib/sus/output/backtrace.rb".freeze, "lib/sus/output/bar.rb".freeze, "lib/sus/output/buffered.rb".freeze, "lib/sus/output/lines.rb".freeze, "lib/sus/output/messages.rb".freeze, "lib/sus/output/null.rb".freeze, "lib/sus/output/progress.rb".freeze, "lib/sus/output/status.rb".freeze, "lib/sus/output/structured.rb".freeze, "lib/sus/output/text.rb".freeze, "lib/sus/output/xterm.rb".freeze, "lib/sus/raise_exception.rb".freeze, "lib/sus/receive.rb".freeze, "lib/sus/registry.rb".freeze, "lib/sus/respond_to.rb".freeze, "lib/sus/shared.rb".freeze, "lib/sus/tree.rb".freeze, "lib/sus/version.rb".freeze, "lib/sus/with.rb".freeze, "license.md".freeze, "readme.md".freeze, "releases.md".freeze] s.homepage = "https://github.com/socketry/sus".freeze s.licenses = ["MIT".freeze] s.required_ruby_version = Gem::Requirement.new(">= 3.2".freeze) s.rubygems_version = "4.0.3".freeze s.summary = "A fast and scalable test runner.".freeze end sus-0.35.2/checksums.yaml.gz.sig0000444000004100000410000000060015152126155016511 0ustar www-datawww-dataHQ?Ri=+]Fߢ{FزuZ4.6.GzW*v4u^i@Y~ b @kZ BY(g *XdEV'U#znIUm1hxT+l`01rx 4xvj.TٜF?JM^X6$_c]P!uc^ ިU}a9=˹>'ѱF3l{I >/!ΚIY 3cR9y֘"Ux*0%2JַoFgԌN. 5zw^UXs_yGr?"J0|ԕhNQ)b;9AW.6FJ^{t@zW?xSJsus-0.35.2/readme.md0000644000004100000410000000627715152126155014241 0ustar www-datawww-data# Sus Sus is a testing framework for Ruby. - It's similar to RSpec but with less baggage and more parallelism. - It uses `expect` style syntax with first-class predicates. - It has direct [support for code coverage](https://github.com/socketry/covered). - It supports the [VSCode Test Runner interface](https://github.com/socketry/sus-vscode). - It's based on my experience writing thousands of tests. - It's easy to extend (see the `sus-fixtures-` gems for examples). [![Development Status](https://github.com/socketry/sus/workflows/Test/badge.svg)](https://github.com/socketry/sus/actions?workflow=Test) ## Lightning Talk: Testing with Sus (2023)
Testing with Sus
## Usage Please see the [project documentation](https://socketry.github.io/sus/) for more details. - [Getting Started](https://socketry.github.io/sus/guides/getting-started/index) - This guide explains how to use the `sus` gem to write tests for your Ruby projects. - [Mocking](https://socketry.github.io/sus/guides/mocking/index) - This guide explains how to use mocking in sus to isolate dependencies and verify interactions in your tests. - [Shared Test Behaviors and Fixtures](https://socketry.github.io/sus/guides/shared-contexts/index) - This guide explains how to use shared test contexts and fixtures in sus to reduce duplication and ensure consistent test behavior across your test suite. ## Releases Please see the [project releases](https://socketry.github.io/sus/releases/index) for all releases. ### v0.35.0 - Add `Sus::Fixtures::TemporaryDirectoryContext`. ### v0.34.0 - Allow `expect(...).to receive(...)` to accept one or more calls (at least once). ### v0.33.0 - Add support for `agent-context` gem. - [`receive` now supports blocks and `and_raise`.](https://socketry.github.io/sus/releases/index#receive-now-supports-blocks-and-and_raise.) ### v0.32.0 - `Sus::Config` now has a `prepare_warnings!` hook which enables deprecated warnings by default. This is generally considered good behaviour for a test framework. ## See Also - [sus-vscode](https://github.com/socketry/sus-vscode) - Visual Studio Code extension for Sus. ## Contributing We welcome contributions to this project. 1. Fork it. 2. Create your feature branch (`git checkout -b my-new-feature`). 3. Commit your changes (`git commit -am 'Add some feature'`). 4. Push to the branch (`git push origin my-new-feature`). 5. Create new Pull Request. ### Developer Certificate of Origin In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. ### Community Guidelines This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. sus-0.35.2/data.tar.gz.sig0000444000004100000410000000060015152126155015261 0ustar www-datawww-data7!S%X GfM|j}'<&nfUfPYC"5[N#bgqxH}H$ZyKLߺ̼Xwd{ay{e'6♢0fM+-,nE~rY{>}~'2"#uurBE uzI>2 uؐOb4"N0 rB7J+ ,u7OД){F~jhE(Cܦ7g b\mfxkXγ)$JL7Isus-0.35.2/license.md0000644000004100000410000000213615152126155014414 0ustar www-datawww-data# MIT License Copyright, 2021-2025, by Samuel Williams. Copyright, 2022, by Brad Schrag. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sus-0.35.2/metadata.gz.sig0000444000004100000410000000060015152126155015343 0ustar www-datawww-dataMVm 4|F=N`!в4 BpK$\9qc@3@䩔A)JXI'TRr"v~, 2o +op='BpYԬh]=O-b4rʕV/?/=jbll2˪J W)-Lޓt`^PzU=YYY5?6y9aӵӄ{lh 筅yǙCr,nkAba= 10 expect(value).to be <= 100 expect(value).to be > 0 expect(value).to be < 1000 expect(value).to be_truthy expect(value).to be_falsey expect(value).to be_nil expect(value).to be_equal(another_value) expect(value).to be_a(Class) ``` ### Strings ```ruby expect(string).to be(:start_with?, "prefix") expect(string).to be(:end_with?, "suffix") expect(string).to be(:match?, /pattern/) expect(string).to be(:include?, "substring") ``` ### Ranges and Tolerance ```ruby expect(value).to be_within(0.1).of(5.0) expect(value).to be_within(5).percent_of(100) ``` ### Method Calls To call methods on the expected object: ```ruby expect(array).to be(:include?, "value") expect(string).to be(:start_with?, "prefix") expect(object).to be(:respond_to?, :method_name) ``` ### Collection Assertions ```ruby expect(array).to have_attributes(length: be == 1) expect(array).to have_value(be > 1) expect(hash).to have_keys(:key1, "key2") expect(hash).to have_keys(key1: be == 1, "key2" => be == 2) ``` ### Attribute Testing ```ruby expect(user).to have_attributes( name: be == "John", age: be >= 18, email: be(:include?, "@") ) ``` ### Exception Assertions ```ruby expect do risky_operation end.to raise_exception(RuntimeError, message: be =~ /expected error message/) ``` ## Combining Predicates Predicates can be nested. ```ruby expect(user).to have_attributes( name: have_attributes( first: be == "John", last: be == "Doe" ), comments: have_value(be =~ /test comment/), created_at: be_within(1.minute).of(Time.now) ) ``` ### Logical Combinations ```ruby expect(value).to (be > 10).and(be < 20) expect(value).to be_a(String).or(be_a(Symbol), be_a(Integer)) ``` ### Custom Predicates You can create custom predicates for more complex assertions: ```ruby def be_small_prime (be == 2).or(be == 3, be == 5, be == 7) end ``` ## Block Expectations ### Testing Blocks ```ruby expect{operation}.to raise_exception(Error) expect{operation}.to have_duration(be < 1.0) ``` ### Performance Testing You should generally avoid testing performance in unit tests, as it will be highly unstable and dependent on the environment. However, if you need to test performance, you can use: ```ruby expect{slow_operation}.to have_duration(be < 2.0) expect{fast_operation}.to have_duration(be < 0.1) ``` - For less unstable performance tests, you can use the `sus-fixtures-time` gem which tries to compensate for the environment by measuring execution time. - For benchmarking, you can use the `sus-fixtures-benchmark` gem which measures a block of code multiple times and reports the execution time. ## File Operations ### Temporary Directories Use `Dir.mktmpdir` for isolated test environments: ```ruby around do |block| Dir.mktmpdir do |root| @root = root block.call end end let(:test_path) {File.join(@root, "test.txt")} it "can create a file" do File.write(test_path, "content") expect(File).to be(:exist?, test_path) end ``` ## Test Output In general, tests should not produce output unless there is an error or failure. ### Informational Output You can use `inform` to print informational messages during tests: ```ruby it "logs an informational message" do rate = copy_data(source, destination) inform "Copied data at #{rate}MB/s" expect(rate).to be > 0 end ``` This can be useful for debugging or providing context during test runs. ### Console Output The `sus-fixtures-console` gem provides a way to suppress and capture console output during tests. If you are using code which generates console output, you can use this gem to capture it and assert on it. ## Running Tests ```bash # Run all tests bundle exec sus # Run specific test file bundle exec sus test/specific_test.rb ``` sus-0.35.2/context/mocking.md0000644000004100000410000001320215152126155016101 0ustar www-datawww-data# Mocking This guide explains how to use mocking in sus to isolate dependencies and verify interactions in your tests. ## Overview When testing code that depends on external services, slow operations, or complex objects, you need a way to control those dependencies without actually invoking them. Mocking allows you to replace method implementations or set expectations on method calls, making your tests faster, more reliable, and easier to maintain. Use mocking when you need: - **Isolation**: Test your code without depending on external services (databases, APIs, file systems) - **Performance**: Avoid slow operations during testing - **Control**: Simulate error conditions or edge cases that are hard to reproduce - **Verification**: Ensure your code calls methods with the correct arguments Sus provides two types of mocking: `receive` for method call expectations and `mock` for replacing method implementations. The `receive` matcher is a subset of full mocking and is used to set expectations on method calls, while `mock` can be used to replace method implementations or set up more complex behavior. **Important**: Mocking non-local objects permanently changes the object's ancestors, so it should be used with care. For local objects, you can use `let` to define the object and then mock it. Sus does not support the concept of test doubles, but you can use `receive` and `mock` to achieve similar functionality. ## Method Call Expectations The `receive(:method)` expectation is used to set up an expectation that a method will be called on an object. You can also specify arguments and return values. However, `receive` is not sequenced, meaning it does not enforce the order of method calls. If you need to enforce the order, use `mock` instead. ### Basic Usage Verify that a method is called: ```ruby describe PaymentProcessor do let(:payment_processor) {subject.new} let(:logger) {Object.new} it "logs payment attempts" do expect(logger).to receive(:info) payment_processor.process_payment(amount: 100, logger: logger) end end ``` ### With Arguments Verify method calls with specific arguments: ```ruby describe EmailService do let(:email_service) {subject.new} let(:smtp_client) {Object.new} it "sends emails with correct recipient and subject" do expect(smtp_client).to receive(:send).with("user@example.com", "Welcome!") email_service.send_welcome_email("user@example.com", smtp_client) end end ``` You can also use more flexible argument matching: - `.with_arguments(be == [arg1, arg2])` for positional arguments - `.with_options(be == {option1: value1})` for keyword arguments - `.with_block` to verify a block is passed ### Returning Values Set up return values for mocked methods: ```ruby describe UserRepository do let(:repository) {subject.new} let(:database) {Object.new} it "retrieves user by ID" do expected_user = {id: 1, name: "Alice"} expect(database).to receive(:find_user).with(1).and_return(expected_user) user = repository.find(1, database) expect(user).to be == expected_user end end ``` ### Raising Exceptions Simulate error conditions: ```ruby describe FileUploader do let(:uploader) {subject.new} let(:storage_service) {Object.new} it "handles storage failures gracefully" do expect(storage_service).to receive(:upload).and_raise(StandardError, "Storage unavailable") expect{uploader.upload_file("data.txt", storage_service)}.to raise_exception(StandardError, message: "Storage unavailable") end end ``` ### Multiple Calls Verify methods are called multiple times: ```ruby describe CacheWarmer do let(:warmer) {subject.new} let(:cache) {Object.new} it "warms multiple cache entries" do expect(cache).to receive(:set).twice.and_return(true) warmer.warm(["key1", "key2"], cache) end end ``` You can also use `.with_call_count(be == 2)` for more flexible call count expectations. ## Mock Objects Mock objects are used to replace method implementations or set up complex behavior. They can be used to intercept method calls, modify arguments, and control the flow of execution. They are thread-local, meaning they only affect the current thread, therefore are not suitable for use in tests that have multiple threads. ### Replacing Method Implementations Replace methods to return controlled values: ```ruby describe ApiClient do let(:http_client) {Object.new} let(:client) {ApiClient.new(http_client)} let(:users) {["Alice", "Bob"]} it "fetches users from API" do mock(http_client) do |mock| mock.replace(:get) do |url, headers: {}| expect(url).to be == "/api/users" expect(headers).to be == {"accept" => "application/json"} users.to_json end end expect(client.fetch_users).to be == users end end ``` ### Advanced Mocking Patterns You can also use: - `mock.before {|...| ...}` to execute code before the original method - `mock.after {|...| ...}` to execute code after the original method - `mock.wrap(:method) {|original, ...| original.call(...)}` to wrap the original method ## Best Practices 1. **Prefer real objects**: Use mocks only when necessary (external services, slow operations, error conditions) 2. **Use dependency injection**: Make dependencies explicit so they can be easily mocked 3. **Mock at boundaries**: Mock external services, not internal implementation details 4. **Keep mocks simple**: Complex mock setups indicate the code might need refactoring ## Common Pitfalls 1. **Over-mocking**: Mocking too much makes tests brittle and less valuable 2. **Thread safety**: Mock objects are thread-local, don't use them in multi-threaded tests 3. **Permanent changes**: Mocking non-local objects permanently changes their ancestors - use `let` for local objects instead