From 27890cb49cf150904edb3aeb38e122389e37ab51 Mon Sep 17 00:00:00 2001 From: Cyril Kato Date: Wed, 1 Jan 2025 22:26:34 +0100 Subject: [PATCH] feat: add Ruby 3.1 support and enhance code documentation --- .github/workflows/main.yml | 1 + .github/workflows/rubocop.yml | 2 +- .rubocop.yml | 8 +- .ruby-version | 2 +- Gemfile.lock | 25 ++- LICENSE.md | 2 +- README.md | 177 ++++++++++++++++--- VERSION.semver | 2 +- docs/_config.yml | 33 +++- docs/_includes/footer.html | 33 ++-- docs/about.md | 23 +++ docs/index.md | 46 +++++ fix.gemspec | 36 ++-- lib/fix.rb | 95 ++++++++-- lib/fix/builder.rb | 101 +++++++++++ lib/fix/doc.rb | 51 +++++- lib/fix/dsl.rb | 29 ++- lib/fix/error/invalid_specification_name.rb | 12 ++ lib/fix/error/missing_specification_block.rb | 14 ++ lib/fix/error/specification_not_found.rb | 12 ++ lib/fix/matcher.rb | 27 +++ lib/fix/requirement.rb | 98 ++++++---- lib/fix/run.rb | 65 +++++-- lib/fix/set.rb | 2 +- lib/kernel.rb | 53 +++--- 25 files changed, 775 insertions(+), 174 deletions(-) create mode 100644 docs/about.md create mode 100644 lib/fix/builder.rb create mode 100644 lib/fix/error/invalid_specification_name.rb create mode 100644 lib/fix/error/missing_specification_block.rb create mode 100644 lib/fix/error/specification_not_found.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 803653f..56222f2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,6 +9,7 @@ jobs: strategy: matrix: ruby: + - 3.1 - 3.2 - 3.3 - 3.4 diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index af4b5ae..f92bd2a 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -10,7 +10,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2 + ruby-version: 3.1 bundler-cache: true - name: Run the RuboCop task diff --git a/.rubocop.yml b/.rubocop.yml index 7cd5695..6f084fe 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,6 @@ AllCops: NewCops: enable - TargetRubyVersion: 3.2 + TargetRubyVersion: 3.1 Exclude: - '**/*.md' @@ -54,3 +54,9 @@ Naming/FileName: Style/ClassAndModuleChildren: Exclude: - README.md + +Naming/BlockForwarding: + Enabled: false + +Style/ArgumentsForwarding: + Enabled: false diff --git a/.ruby-version b/.ruby-version index b347b11..9cec716 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.3 +3.1.6 diff --git a/Gemfile.lock b/Gemfile.lock index 9d1f1e0..ef04a1d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,21 +1,21 @@ PATH remote: . specs: - fix (1.0.0.beta12) - defi (~> 3.0.0) - matchi (~> 4.1.0) - spectus (~> 5.0.1) + fix (0.19) + defi (~> 3.0.1) + matchi (~> 4.1.1) + spectus (~> 5.0.2) GEM remote: https://rubygems.org/ specs: ast (2.4.2) - defi (3.0.0) + defi (3.0.1) docile (1.4.1) - expresenter (1.5.0) + expresenter (1.5.1) json (2.9.1) language_server-protocol (3.17.0.3) - matchi (4.1.0) + matchi (4.1.1) parallel (1.26.3) parser (3.3.6.0) ast (~> 2.4.1) @@ -52,12 +52,11 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - spectus (5.0.1) - expresenter (~> 1.5.0) - matchi (~> 4.0) - test_tube (~> 4.0.0) - test_tube (4.0.0) - defi (~> 3.0.0) + spectus (5.0.2) + expresenter (~> 1.5.1) + test_tube (~> 4.0.1) + test_tube (4.0.1) + defi (~> 3.0.1) unicode-display_width (3.1.3) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) diff --git a/LICENSE.md b/LICENSE.md index 4e04420..520f543 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # The MIT License -Copyright (c) 2014-2024 Cyril Kato +Copyright (c) 2014-2025 Cyril Kato Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b254363..bda72cb 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,16 @@ Fix is a modern Ruby testing framework that emphasizes clear separation between ## Installation +### Prerequisites + +- Ruby >= 3.1.0 + +### Setup + Add to your Gemfile: ```ruby -gem "fix", ">= 1.0.0.beta12" +gem "fix" ``` Then execute: @@ -26,7 +32,7 @@ bundle install Or install it yourself: ```sh -gem install fix --pre +gem install fix ``` ## Core Principles @@ -171,13 +177,36 @@ Fix :UserAccount do end ``` -This example demonstrates: -- Using `let` to define test fixtures -- Context-specific testing with `with` -- Method behavior testing with `on` -- Different requirement levels with `MUST`/`MUST_NOT` -- Testing state changes with the `change` matcher -- Nested contexts for complex scenarios +The implementation might look like this: + +```ruby +class User + attr_reader :role, :password_hash + + def initialize(role:) + @role = role + @password_hash = nil + end + + def admin? + role == "admin" + end + + def can_access?(resource) + return true if admin? + false + end + + def full_name + "#{@first_name} #{@last_name}" + end + + def update_password(new_password) + @password_hash = Digest::SHA256.hexdigest(new_password) + true + end +end +``` ### Example 2: Duck Specification @@ -225,47 +254,140 @@ Running the test: ```ruby Fix[:Duck].test { Duck.new } ``` - ## Available Matchers Fix includes a comprehensive set of matchers through its integration with the [Matchi library](https://github.com/fixrb/matchi): -### Basic Comparison Matchers +
+Basic Comparison Matchers + - `eq(expected)` - Tests equality using `eql?` + ```ruby + it MUST eq(42) # Passes if value.eql?(42) + it MUST eq("hello") # Passes if value.eql?("hello") + ``` +- `eql(expected)` - Alias for eq - `be(expected)` - Tests object identity using `equal?` + ```ruby + string = "test" + it MUST be(string) # Passes only if it's exactly the same object + ``` +- `equal(expected)` - Alias for be +
+ +
+Type Checking Matchers -### Type Checking Matchers - `be_an_instance_of(class)` - Verifies exact class match + ```ruby + it MUST be_an_instance_of(Array) # Passes if value.instance_of?(Array) + it MUST be_an_instance_of(User) # Passes if value.instance_of?(User) + ``` - `be_a_kind_of(class)` - Checks class inheritance and module inclusion + ```ruby + it MUST be_a_kind_of(Enumerable) # Passes if value.kind_of?(Enumerable) + it MUST be_a_kind_of(Animal) # Passes if value inherits from Animal + ``` +
+ +
+Change Testing Matchers -### Change Testing Matchers - `change(object, method)` - Base matcher for state changes - `.by(n)` - Expects exact change by n + ```ruby + it MUST change(user, :points).by(5) # Exactly +5 points + ``` - `.by_at_least(n)` - Expects minimum change by n + ```ruby + it MUST change(counter, :value).by_at_least(10) # At least +10 + ``` - `.by_at_most(n)` - Expects maximum change by n + ```ruby + it MUST change(account, :balance).by_at_most(100) # No more than +100 + ``` - `.from(old).to(new)` - Expects change from old to new value + ```ruby + it MUST change(user, :status).from("pending").to("active") + ``` - `.to(new)` - Expects change to new value + ```ruby + it MUST change(post, :title).to("Updated") + ``` +
+ +
+Numeric Matchers -### Numeric Matchers - `be_within(delta).of(value)` - Tests if a value is within ±delta of expected value + ```ruby + it MUST be_within(0.1).of(3.14) # Passes if value is between 3.04 and 3.24 + it MUST be_within(5).of(100) # Passes if value is between 95 and 105 + ``` +
+ +
+Pattern Matchers -### Pattern Matchers - `match(regex)` - Tests string against regular expression pattern + ```ruby + it MUST match(/^\d{3}-\d{2}-\d{4}$/) # SSN format + it MUST match(/^[A-Z][a-z]+$/) # Capitalized word + ``` - `satisfy { |value| ... }` - Custom matching with block + ```ruby + it MUST satisfy { |num| num.even? && num > 0 } + it MUST satisfy { |user| user.valid? && user.active? } + ``` +
-### State Matchers -- `be_true` - Tests for true -- `be_false` - Tests for false -- `be_nil` - Tests for nil +
+Exception Matchers -### Exception Matchers - `raise_exception(class)` - Tests if code raises specified exception + ```ruby + it MUST raise_exception(ArgumentError) + it MUST raise_exception(CustomError, "specific message") + ``` +
-### Dynamic Predicate Matchers -- `be_*` - Dynamically matches `object.*?` methods (e.g., `be_empty` calls `empty?`) -- `have_*` - Dynamically matches `object.has_*?` methods (e.g., `have_key` calls `has_key?`) +
+State Matchers -Example usage: +- `be_true` - Tests for true + ```ruby + it MUST be_true # Only passes for true, not truthy values + ``` +- `be_false` - Tests for false + ```ruby + it MUST be_false # Only passes for false, not falsey values + ``` +- `be_nil` - Tests for nil + ```ruby + it MUST be_nil # Passes only for nil + ``` +
+ +
+Dynamic Predicate Matchers + +- `be_*` - Dynamically matches `object.*?` method + ```ruby + it MUST be_empty # Calls empty? + it MUST be_valid # Calls valid? + it MUST be_frozen # Calls frozen? + ``` +- `have_*` - Dynamically matches `object.has_*?` method + ```ruby + it MUST have_key(:id) # Calls has_key? + it MUST have_errors # Calls has_errors? + it MUST have_permission # Calls has_permission? + ``` +
+ +### Complete Example + +Here's an example using various matchers together: ```ruby Fix :Calculator do @@ -284,6 +406,13 @@ Fix :Calculator do it MUST_NOT be_empty it MUST satisfy { |result| result.all? { |n| n.positive? } } end + + with string_input: "123" do + on :parse do + it MUST be_a_kind_of Numeric + it MUST satisfy { |n| n > 0 } + end + end end ``` diff --git a/VERSION.semver b/VERSION.semver index bbbce90..caa4836 100644 --- a/VERSION.semver +++ b/VERSION.semver @@ -1 +1 @@ -1.0.0.beta12 +0.19 diff --git a/docs/_config.yml b/docs/_config.yml index 4a5b451..7909db1 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,21 +1,48 @@ -title: Fix specing framework +title: "Fix: Happy Path to Ruby Testing" author: name: Cyril Kato + url: "https://github.com/cyril" + description: > Fix is a modern Ruby testing framework built around a key architectural principle: the complete separation between specifications and tests. It allows you to write pure specification documents that define expected behaviors, and then independently challenge any implementation against these specifications. + remote_theme: jekyll/minima plugins: - jekyll-feed - jekyll-remote-theme + - jekyll-sitemap + - jekyll-seo-tag + minima: skin: auto social_links: - - { platform: github, user_url: "https://github.com/fixrb/fix" } - - { platform: x, user_url: "https://x.com/fix_rb" } + - { platform: github, user_url: "https://github.com/fixrb/fix" } + - { platform: x, user_url: "https://x.com/fix_rb" } + - { platform: bsky, user_url: "https://bsky.app/profile/fixrb.dev" } + - { platform: rss, user_url: "/feed.xml" } + +# Feed configuration feed: icon: /favicon.png logo: /favicon.png posts_limit: 100 + +# SEO +twitter: + username: fix_rb + card: summary + +social: + name: Fix Framework + links: + - https://github.com/fixrb/fix + - https://x.com/fix_rb + - https://bsky.app/profile/fixrb.dev + +# GitHub Pages specific +github: + is_project_page: true + repository_url: "https://github.com/fixrb/fix" diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html index f81886f..4896887 100644 --- a/docs/_includes/footer.html +++ b/docs/_includes/footer.html @@ -2,36 +2,33 @@
- -
- diff --git a/docs/about.md b/docs/about.md new file mode 100644 index 0000000..9bc6c8b --- /dev/null +++ b/docs/about.md @@ -0,0 +1,23 @@ +--- +layout: page +title: About +permalink: /about/ +--- + +## About Fix + +Fix is a modern Ruby testing framework conceived with a fundamental architectural principle: strict separation between specifications and their implementation. This unique approach allows developers to write clearer, more maintainable tests while providing solid guarantees about their code's behavior. + +### Philosophy + +Fix was born from a simple observation: testing frameworks shouldn't be more complex than the code they test. Built with minimalism in mind and powered by modular components (Defi, Matchi, Spectus), Fix brings clarity and joy to Ruby testing. + +### Key Components + +- **[Defi](https://github.com/fixrb/defi)**: Method handling abstraction +- **[Matchi](https://github.com/fixrb/matchi)**: Collection of expectation matchers +- **[Spectus](https://github.com/fixrb/spectus)**: Test runner with RFC 2119 semantics + +### Project History + +Originally released in 2015, Fix was created to address the growing complexity in Ruby testing frameworks. The project has evolved while maintaining its core principle: making testing both rigorous and enjoyable. diff --git a/docs/index.md b/docs/index.md index e69de29..08f253a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -0,0 +1,46 @@ +--- +layout: home +title: "Fix: Happy Path to Ruby Testing" +--- + +## Modern Ruby Testing Framework + +Fix is a modern Ruby testing framework that emphasizes clear separation between specifications and examples. Unlike traditional testing frameworks, Fix focuses on creating pure specification documents that define expected behaviors without mixing in implementation details. + +### Getting Started + +Add to your Gemfile: + +```ruby +gem "fix" +``` + +Or install it yourself: + +```sh +gem install fix +``` + +### Key Features + +- **Pure Specifications**: Write specification documents that focus solely on defining behaviors +- **Semantic Precision**: Use MUST, SHOULD, and MAY to clearly define requirement levels +- **Clear Separation**: Keep specifications and implementations separate for better maintainability +- **Fast Execution**: Run tests quickly and independently +- **Rich Matcher Library**: Comprehensive set of matchers for different testing needs + +### Quick Example + +```ruby +# Define your specification +Fix :Calculator do + on(:add, 2, 3) do + it MUST eq 5 + end +end + +# Test your implementation +Fix[:Calculator].test { Calculator } +``` + +Discover more about Fix in our [documentation](https://rubydoc.info/gems/fix) or check our [source code](https://github.com/fixrb/fix). diff --git a/fix.gemspec b/fix.gemspec index e6d66c4..225634f 100644 --- a/fix.gemspec +++ b/fix.gemspec @@ -1,27 +1,35 @@ # frozen_string_literal: true Gem::Specification.new do |spec| - spec.name = "fix" - spec.version = File.read("VERSION.semver").chomp - spec.author = "Cyril Kato" - spec.email = "contact@cyril.email" - spec.summary = "Specing framework." - spec.description = spec.summary - spec.homepage = "https://fixrb.dev/" - spec.license = "MIT" - spec.files = Dir["LICENSE.md", "README.md", "lib/**/*"] + spec.name = "fix" + spec.version = File.read("VERSION.semver").chomp + spec.author = "Cyril Kato" + spec.email = "contact@cyril.email" + spec.summary = "Happy Path to Ruby Testing" - spec.required_ruby_version = ">= 3.2.0" + spec.description = <<~DESC + Fix is a modern Ruby testing framework built around a key architectural principle: + the complete separation between specifications and tests. It allows you to write + pure specification documents that define expected behaviors, and then independently + challenge any implementation against these specifications. + DESC + + spec.homepage = "https://fixrb.dev/" + spec.license = "MIT" + spec.files = Dir["LICENSE.md", "README.md", "lib/**/*"] + + spec.required_ruby_version = ">= 3.1.0" spec.metadata = { "bug_tracker_uri" => "https://github.com/fixrb/fix/issues", + "changelog_uri" => "https://github.com/fixrb/fix/blob/main/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/gems/fix", + "homepage_uri" => "https://fixrb.dev", "source_code_uri" => "https://github.com/fixrb/fix", - "wiki_uri" => "https://github.com/fixrb/fix/wiki", "rubygems_mfa_required" => "true" } - spec.add_dependency "defi", "~> 3.0.0" - spec.add_dependency "matchi", "~> 4.1.0" - spec.add_dependency "spectus", "~> 5.0.1" + spec.add_dependency "defi", "~> 3.0.1" + spec.add_dependency "matchi", "~> 4.1.1" + spec.add_dependency "spectus", "~> 5.0.2" end diff --git a/lib/fix.rb b/lib/fix.rb index f83fdec..c17a766 100644 --- a/lib/fix.rb +++ b/lib/fix.rb @@ -1,22 +1,93 @@ # frozen_string_literal: true -require_relative File.join("fix", "set") - +require_relative "fix/doc" +require_relative "fix/error/specification_not_found" +require_relative "fix/set" require_relative "kernel" # Namespace for the Fix framework. # +# Provides core functionality for managing and running test specifications. +# Fix supports two modes of operation: +# 1. Named specifications that can be referenced later +# 2. Anonymous specifications for immediate testing +# +# @example Creating and running a named specification +# Fix :Answer do +# it MUST equal 42 +# end +# +# Fix[:Answer].test { 42 } +# +# @example Creating and running an anonymous specification +# Fix do +# it MUST be_positive +# end.test { 42 } +# +# @see Fix::Set +# @see Fix::Builder +# # @api public module Fix - # Test a built specification. - # - # @example Run _Answer_ specification against `42`. - # Fix[:Answer].test { 42 } - # - # @param name [String, Symbol] The constant name of the specifications. - # - # @return [::Fix::Test] The specification document. - def self.[](name) - ::Fix::Set.load(name) + class << self + # Retrieves and loads a built specification for testing. + # + # @example Run a named specification + # Fix[:Answer].test { 42 } + # + # @param name [String, Symbol] The constant name of the specification + # @return [Fix::Set] The loaded specification set ready for testing + # @raise [Fix::Error::SpecificationNotFound] If the named specification doesn't exist + def [](name) + name = normalize_name(name) + validate_specification_exists!(name) + Set.load(name) + end + + # Lists all defined specification names. + # + # @example Get all specification names + # Fix.specification_names #=> [:Answer, :Calculator, :UserProfile] + # + # @return [Array] Sorted array of specification names + def specification_names + Doc.constants.sort + end + + # Checks if a specification is defined. + # + # @example Check for specification existence + # Fix.spec_defined?(:Answer) #=> true + # + # @param name [String, Symbol] Name of the specification to check + # @return [Boolean] true if specification exists, false otherwise + def spec_defined?(name) + specification_names.include?(normalize_name(name)) + end + + private + + # Converts any specification name into a symbol. + # This allows for consistent name handling regardless of input type. + # + # @param name [String, Symbol] The name to normalize + # @return [Symbol] The normalized name + # @example + # normalize_name("Answer") #=> :Answer + # normalize_name(:Answer) #=> :Answer + def normalize_name(name) + String(name).to_sym + end + + # Verifies the existence of a specification and raises an error if not found. + # This ensures early failure when attempting to use undefined specifications. + # + # @param name [Symbol] The specification name to validate + # @raise [Fix::Error::SpecificationNotFound] If specification doesn't exist + def validate_specification_exists!(name) + return if spec_defined?(name) + + raise Error::SpecificationNotFound, name + end end end diff --git a/lib/fix/builder.rb b/lib/fix/builder.rb new file mode 100644 index 0000000..e7122bb --- /dev/null +++ b/lib/fix/builder.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require_relative "doc" +require_relative "dsl" +require_relative "set" +require_relative "error/missing_specification_block" + +module Fix + # Handles the creation and setup of Fix specifications. + # + # The Builder constructs new Fix specification sets following these steps: + # 1. Creates a new specification class inheriting from DSL + # 2. Defines the specification content using the provided block + # 3. Optionally registers the named specification + # 4. Returns the built specification set + # + # @example Create a named specification + # Fix::Builder.build(:Calculator) do + # on(:add, 2, 3) { it MUST equal 5 } + # end + # + # @example Create an anonymous specification + # Fix::Builder.build do + # it MUST be_positive + # end + # + # @see Fix::Set + # @see Fix::Dsl + # @api private + class Builder + # Creates a new specification set. + # + # @param name [String, Symbol, nil] Optional name for the specification + # @yieldparam [void] Block containing specification definitions + # @yieldreturn [void] + # @return [Fix::Set] The constructed specification set + # @raise [Fix::Error::InvalidSpecificationName] If name is invalid + # @raise [Fix::Error::MissingSpecificationBlock] If no block given + def self.build(name = nil, &block) + new(name, &block).construct_set + end + + # @return [String, Symbol, nil] The name of the specification + attr_reader :name + + def initialize(name = nil, &block) + raise Error::MissingSpecificationBlock unless block + + @name = name + @block = block + end + + # Constructs and returns a new specification set. + # + # @return [Fix::Set] The constructed specification set + def construct_set + klass = create_specification + populate_specification(klass) + register_if_named(klass) + build_set(klass) + end + + private + + # @return [Proc] The block containing specification definitions + attr_reader :block + + # Creates a new specification class with context tracking. + # + # @return [Class] A new class inheriting from Fix::Dsl with CONTEXTS initialized + def create_specification + ::Class.new(Dsl).tap do |klass| + klass.const_set(:CONTEXTS, [klass]) + end + end + + # Evaluates the specification block in the context of the class. + # + # @param klass [Class] The class to populate with specifications + # @return [void] + def populate_specification(klass) + klass.instance_eval(&block) + end + + # Registers the specification in Fix::Doc if a name was provided. + # + # @param klass [Class] The specification class to register + # @return [void] + def register_if_named(klass) + Doc.spec_set(name, klass) if name + end + + # Creates a new specification set from the populated class. + # + # @param klass [Class] The populated specification class + # @return [Fix::Set] A new specification set + def build_set(klass) + Set.new(*klass.const_get(:CONTEXTS)) + end + end +end diff --git a/lib/fix/doc.rb b/lib/fix/doc.rb index e221cc7..d799014 100644 --- a/lib/fix/doc.rb +++ b/lib/fix/doc.rb @@ -1,24 +1,59 @@ # frozen_string_literal: true +require_relative "error/invalid_specification_name" + module Fix - # Module for storing spec documents. + # Module for storing and managing specification documents. + # + # This module acts as a registry for specification classes and handles + # the extraction of test specifications from context objects. # # @api private module Doc - # @param name [String, Symbol] The constant name of the specifications. + # Retrieves the contexts array for a named specification. + # + # @param name [String, Symbol] The constant name of the specification + # @return [Array] Array of context classes for the specification + # @raise [NameError] If specification constant is not found def self.fetch(name) const_get("#{name}::CONTEXTS") end - # @param contexts [Array<::Fix::Dsl>] The list of contexts document. - def self.specs(*contexts) + # Extracts test specifications from a list of context classes. + # Each specification consists of an environment and its associated test data. + # + # @param contexts [Array] List of context classes to process + # @return [Array] Array of arrays where each sub-array contains: + # - [0] environment: The test environment instance + # - [1] location: The test file location (as "path:line") + # - [2] requirement: The test requirement (MUST, SHOULD, or MAY) + # - [3] challenges: Array of test challenges to execute + def self.extract_specifications(*contexts) contexts.flat_map do |context| - env = context.new + extract_context_specifications(context) + end + end - env.public_methods(false).map do |public_method| - [env] + env.public_send(public_method) - end + # Registers a new specification class under the given name. + # + # @param name [String, Symbol] Name to register the specification under + # @param klass [Class] The specification class to register + # @raise [Fix::Error::InvalidSpecificationName] If name is not a valid constant name + # @return [void] + def self.spec_set(name, klass) + const_set(name, klass) + rescue ::NameError => _e + raise Error::InvalidSpecificationName, name + end + + # @private + def self.extract_context_specifications(context) + env = context.new + env.public_methods(false).map do |public_method| + [env] + env.public_send(public_method) end end + + private_class_method :extract_context_specifications end end diff --git a/lib/fix/dsl.rb b/lib/fix/dsl.rb index d660564..09deeb9 100644 --- a/lib/fix/dsl.rb +++ b/lib/fix/dsl.rb @@ -26,7 +26,7 @@ class Dsl # @yield The block that defines the property's value # @yieldreturn [Object] The value to be returned by the property # - # @return [Symbol] A private method that define the block content. + # @return [Symbol] A private method that defines the block content. # # @api public def self.let(name, &) @@ -45,10 +45,12 @@ def self.let(name, &) # end # end # - # @param kwargs [Hash] The list of propreties. + # @param kwargs [Hash] The list of properties to define in this context # @yield The block that defines the specs for this context # @yieldreturn [void] # + # @return [Class] A new class representing this context + # # @api public def self.with(**kwargs, &) klass = ::Class.new(self) @@ -69,15 +71,21 @@ def self.with(**kwargs, &) # end # end # - # @param method_name [String, Symbol] The method to send to the subject. - # @param block [Proc] The block to define the specs. + # @param method_name [String, Symbol] The method to send to the subject + # @param args [Array] Positional arguments to pass to the method + # @param kwargs [Hash] Keyword arguments to pass to the method + # @yield The block containing the specifications for this context + # @yieldreturn [void] + # + # @return [Class] A new class representing this context # # @api public def self.on(method_name, *args, **kwargs, &block) klass = ::Class.new(self) klass.const_get(:CONTEXTS) << klass - const_set(:"Child#{block.object_id}", klass) + const_name = :"MethodContext_#{block.object_id}" + const_set(const_name, klass) klass.define_singleton_method(:challenges) do challenge = ::Defi::Method.new(method_name, *args, **kwargs) @@ -99,6 +107,14 @@ def self.on(method_name, *args, **kwargs, &block) # it { MUST be 42 } # end # + # @param requirement [Object, nil] The requirement to test + # @yield A block defining the requirement if not provided directly + # @yieldreturn [Object] The requirement definition + # + # @return [Symbol] Name of the generated test method + # + # @raise [ArgumentError] If neither or both requirement and block are provided + # # @api public def self.it(requirement = nil, &block) raise ::ArgumentError, "Must provide either requirement or block, not both" if requirement && block @@ -107,7 +123,8 @@ def self.it(requirement = nil, &block) location = caller_locations(1, 1).fetch(0) location = [location.path, location.lineno].join(":") - define_method(:"test_#{(requirement || block).object_id}") do + test_method_name = :"test_#{(requirement || block).object_id}" + define_method(test_method_name) do [location, requirement || singleton_class.class_eval(&block), self.class.challenges] end end diff --git a/lib/fix/error/invalid_specification_name.rb b/lib/fix/error/invalid_specification_name.rb new file mode 100644 index 0000000..963abaf --- /dev/null +++ b/lib/fix/error/invalid_specification_name.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Fix + module Error + # Error raised when an invalid specification name is provided during declaration + class InvalidSpecificationName < ::NameError + def initialize(name) + super("Invalid specification name '#{name}'. Specification names must be valid Ruby constants.") + end + end + end +end diff --git a/lib/fix/error/missing_specification_block.rb b/lib/fix/error/missing_specification_block.rb new file mode 100644 index 0000000..b471a48 --- /dev/null +++ b/lib/fix/error/missing_specification_block.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Fix + module Error + # Error raised when attempting to build a specification without a block + class MissingSpecificationBlock < ::ArgumentError + MISSING_BLOCK_ERROR = "Block is required for building a specification" + + def initialize + super(MISSING_BLOCK_ERROR) + end + end + end +end diff --git a/lib/fix/error/specification_not_found.rb b/lib/fix/error/specification_not_found.rb new file mode 100644 index 0000000..6e99ffc --- /dev/null +++ b/lib/fix/error/specification_not_found.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Fix + module Error + # Error raised when a specification cannot be found at runtime + class SpecificationNotFound < ::NameError + def initialize(name) + super("Specification '#{name}' not found. Make sure it's defined before running the test.") + end + end + end +end diff --git a/lib/fix/matcher.rb b/lib/fix/matcher.rb index b1c28d3..7d81f8b 100644 --- a/lib/fix/matcher.rb +++ b/lib/fix/matcher.rb @@ -4,44 +4,71 @@ module Fix # Collection of expectation matchers. + # Provides a comprehensive set of matchers for testing different aspects of objects. # # The following matchers are available: # # Basic Comparison: # - eq(expected) # Checks equality using eql? + # it MUST eq(42) + # it MUST eq("hello") # - eql(expected) # Alias for eq # - be(expected) # Checks exact object identity using equal? + # string = "test" + # it MUST be(string) # Passes only if it's the same object # - equal(expected) # Alias for be # # Type Checking: # - be_an_instance_of(class) # Checks exact class match + # it MUST be_an_instance_of(Array) # - be_a_kind_of(class) # Checks class inheritance and module inclusion + # it MUST be_a_kind_of(Enumerable) # # State & Changes: # - change(object, method) # Base for checking state changes # .by(n) # Exact change by n + # it MUST change(user, :points).by(5) # .by_at_least(n) # Minimum change by n + # it MUST change(counter, :value).by_at_least(10) # .by_at_most(n) # Maximum change by n + # it MUST change(account, :balance).by_at_most(100) # .from(old).to(new) # Change from old to new value + # it MUST change(user, :status).from("pending").to("active") # .to(new) # Change to new value + # it MUST change(post, :title).to("Updated") # # Value Testing: # - be_within(delta).of(value) # Checks numeric value within delta + # it MUST be_within(0.1).of(3.14) # - match(regex) # Tests against regular expression + # it MUST match(/^\d{3}-\d{2}-\d{4}$/) # SSN format # - satisfy { |value| ... } # Custom matcher with block + # it MUST satisfy { |num| num.even? && num > 0 } # # Exceptions: # - raise_exception(class) # Checks if code raises exception + # it MUST raise_exception(ArgumentError) + # it MUST raise_exception(CustomError, "specific message") # # State Testing: # - be_true # Tests for true + # it MUST be_true # Only passes for true, not truthy values # - be_false # Tests for false + # it MUST be_false # Only passes for false, not falsey values # - be_nil # Tests for nil + # it MUST be_nil # # Predicate Matchers: # - be_* # Matches object.*? method + # it MUST be_empty # Calls empty? + # it MUST be_valid # Calls valid? + # it MUST be_frozen # Calls frozen? # - have_* # Matches object.has_*? method + # it MUST have_key(:id) # Calls has_key? + # it MUST have_errors # Calls has_errors? # + # @note All matchers can be used with MUST, MUST_NOT, SHOULD, SHOULD_NOT, and MAY + # @see https://github.com/fixrb/matchi for more details about the matchers # @api private module Matcher include Matchi diff --git a/lib/fix/requirement.rb b/lib/fix/requirement.rb index 617ffa1..408d20f 100644 --- a/lib/fix/requirement.rb +++ b/lib/fix/requirement.rb @@ -5,77 +5,109 @@ require "spectus/requirement/required" module Fix - # Collection of expectation matchers. + # Implements requirement levels as defined in RFC 2119. + # Provides methods for specifying different levels of requirements + # in test specifications: MUST, SHOULD, and MAY. # # @api private module Requirement # rubocop:disable Naming/MethodName - # This method mean that the definition is an absolute requirement of the + # This method means that the definition is an absolute requirement of the # specification. # - # @param matcher [#match?] The matcher. + # @example Test exact equality + # it MUST eq(42) # - # @return [Requirement::Required] An absolute requirement level instance. + # @example Test type matching + # it MUST be_an_instance_of(User) + # + # @example Test state changes + # it MUST change(user, :status).from("pending").to("active") + # + # @param matcher [#match?] The matcher that defines the required condition + # @return [::Spectus::Requirement::Required] An absolute requirement level instance # # @api public def MUST(matcher) ::Spectus::Requirement::Required.new(negate: false, matcher:) end - # This method mean that the definition is an absolute prohibition of the specification. + # This method means that the definition is an absolute prohibition of the + # specification. # - # @param matcher [#match?] The matcher. + # @example Test prohibited state + # it MUST_NOT be_nil # - # @return [Requirement::Required] An absolute prohibition level instance. + # @example Test prohibited type + # it MUST_NOT be_a_kind_of(AdminUser) + # + # @example Test prohibited exception + # it MUST_NOT raise_exception(SecurityError) + # + # @param matcher [#match?] The matcher that defines the prohibited condition + # @return [::Spectus::Requirement::Required] An absolute prohibition level instance # # @api public def MUST_NOT(matcher) ::Spectus::Requirement::Required.new(negate: true, matcher:) end - # This method mean that there may exist valid reasons in particular - # circumstances to ignore a particular item, but the full implications must be - # understood and carefully weighed before choosing a different course. + # This method means that there may exist valid reasons in particular + # circumstances to ignore this requirement, but the implications must be + # understood and carefully weighed. + # + # @example Test numeric boundaries + # it SHOULD be_within(0.1).of(expected_value) # - # @param matcher [#match?] The matcher. + # @example Test pattern matching + # it SHOULD match(/^[A-Z][a-z]+$/) # - # @return [Requirement::Recommended] A recommended requirement level instance. + # @example Test custom condition + # it SHOULD satisfy { |obj| obj.valid? && obj.complete? } + # + # @param matcher [#match?] The matcher that defines the recommended condition + # @return [::Spectus::Requirement::Recommended] A recommended requirement level instance # # @api public def SHOULD(matcher) ::Spectus::Requirement::Recommended.new(negate: false, matcher:) end - # This method mean that there may exist valid reasons in particular - # circumstances when the particular behavior is acceptable or even useful, but - # the full implications should be understood and the case carefully weighed - # before implementing any behavior described with this label. + # This method means that there may exist valid reasons in particular + # circumstances when the behavior is acceptable, but the implications should be + # understood and weighed carefully. + # + # @example Test state changes to avoid + # it SHOULD_NOT change(object, :state) # - # @param matcher [#match?] The matcher. + # @example Test predicate conditions to avoid + # it SHOULD_NOT be_empty + # it SHOULD_NOT have_errors # - # @return [Requirement::Recommended] A not recommended requirement level - # instance. + # @param matcher [#match?] The matcher that defines the discouraged condition + # @return [::Spectus::Requirement::Recommended] A discouraged requirement level instance # # @api public def SHOULD_NOT(matcher) ::Spectus::Requirement::Recommended.new(negate: true, matcher:) end - # This method mean that an item is truly optional. - # One vendor may choose to include the item because a particular marketplace - # requires it or because the vendor feels that it enhances the product while - # another vendor may omit the same item. An implementation which does not - # include a particular option must be prepared to interoperate with another - # implementation which does include the option, though perhaps with reduced - # functionality. In the same vein an implementation which does include a - # particular option must be prepared to interoperate with another - # implementation which does not include the option (except, of course, for the - # feature the option provides). - # - # @param matcher [#match?] The matcher. - # - # @return [Requirement::Optional] An optional requirement level instance. + # This method means that the item is truly optional. Implementations may + # include this feature if it enhances their product, and must be prepared to + # interoperate with implementations that include or omit this feature. + # + # @example Test optional functionality + # it MAY respond_to(:cache_key) + # + # @example Test optional state + # it MAY be_frozen + # + # @example Test optional predicates + # it MAY have_attachments + # + # @param matcher [#match?] The matcher that defines the optional condition + # @return [::Spectus::Requirement::Optional] An optional requirement level instance # # @api public def MAY(matcher) diff --git a/lib/fix/run.rb b/lib/fix/run.rb index 1eaa1b5..fc38e15 100644 --- a/lib/fix/run.rb +++ b/lib/fix/run.rb @@ -3,34 +3,62 @@ require "expresenter/fail" module Fix - # Run class. + # Executes a test specification by running a subject against a set of challenges + # and requirements. + # + # The Run class orchestrates test execution by: + # 1. Evaluating the test subject in the proper environment + # 2. Applying a series of method challenges to the result + # 3. Verifying the final value against the requirement + # + # @example Running a simple test + # run = Run.new(env, requirement) + # run.test { MyClass.new } + # + # @example Running with method challenges + # run = Run.new(env, requirement, challenge1, challenge2) + # run.test { MyClass.new } # Will call methods defined in challenges # # @api private class Run - # @return [::Fix::Dsl] A context instance. + # The test environment containing defined variables and methods + # @return [::Fix::Dsl] A context instance attr_reader :environment - # @return [::Spectus::Requirement::Base] An expectation. + # The specification requirement to validate against + # @return [::Spectus::Requirement::Base] An expectation attr_reader :requirement - # @return [Array<::Defi::Method>] A list of challenges. + # The list of method calls to apply to the subject + # @return [Array<::Defi::Method>] A list of challenges attr_reader :challenges - # @param environment [::Fix::Dsl] A context instance. - # @param requirement [::Spectus::Requirement::Base] An expectation. - # @param challenges [Array<::Defi::Method>] A list of challenges. + # Initializes a new test run with the given environment and challenges. + # + # @param environment [::Fix::Dsl] Context instance with test setup + # @param requirement [::Spectus::Requirement::Base] Expectation to verify + # @param challenges [Array<::Defi::Method>] Method calls to apply + # + # @example + # Run.new(test_env, must_be_positive, increment_method) def initialize(environment, requirement, *challenges) @environment = environment @requirement = requirement - @challenges = challenges + @challenges = challenges end - # Verify if the object checks the condition. + # Verifies if the subject meets the requirement after applying all challenges. + # + # @param subject [Proc] The block of code to be tested + # + # @raise [::Expresenter::Fail] When the test specification fails + # @return [::Expresenter::Pass] When the test specification passes # - # @param subject [Proc] The block of code to be tested. + # @example Basic testing + # run.test { 42 } # - # @raise [::Expresenter::Fail] A failed spec exception. - # @return [::Expresenter::Pass] A passed spec instance. + # @example Testing with subject modification + # run.test { User.new(name: "John") } # # @see https://github.com/fixrb/expresenter def test(&subject) @@ -41,13 +69,18 @@ def test(&subject) private - # The test's actual value. + # Computes the final value to test by applying all challenges to the subject. # - # @param subject [Proc] The block of code to be tested. + # @param subject [Proc] The initial test subject + # @return [#object_id] The final value after applying all challenges # - # @return [#object_id] The actual value to be tested. + # @example Internal process + # # If challenges are [:upcase, :reverse] + # # and subject returns "hello" + # # actual_value will return "OLLEH" def actual_value(&subject) - challenges.inject(environment.instance_eval(&subject)) do |obj, challenge| + initial_value = environment.instance_eval(&subject) + challenges.inject(initial_value) do |obj, challenge| challenge.to(obj).call end end diff --git a/lib/fix/set.rb b/lib/fix/set.rb index aeadf7d..39b760c 100644 --- a/lib/fix/set.rb +++ b/lib/fix/set.rb @@ -27,7 +27,7 @@ def self.load(name) # # @param contexts [Array<::Fix::Dsl>] The list of contexts document. def initialize(*contexts) - @specs = Doc.specs(*contexts).shuffle + @specs = Doc.extract_specifications(*contexts).shuffle end # Run the test suite against the provided subject. diff --git a/lib/kernel.rb b/lib/kernel.rb index e3f2e30..3d50f51 100644 --- a/lib/kernel.rb +++ b/lib/kernel.rb @@ -1,37 +1,48 @@ # frozen_string_literal: true -require_relative File.join("fix", "doc") -require_relative File.join("fix", "dsl") -require_relative File.join("fix", "set") +require_relative "fix/builder" -# The Kernel module. +# Extension of the global Kernel module to provide the Fix method. +# This allows Fix to be called from anywhere in the application +# without explicit namespace qualification. +# +# @api public module Kernel # rubocop:disable Naming/MethodName - # Specifications are built with this method. + # This rule is disabled because Fix is intentionally capitalized to act as + # both a namespace and a method name, following Ruby conventions for DSLs. + + # Defines a new test specification or creates an anonymous specification set. + # When a name is provided, the specification is registered globally and can + # be referenced later using Fix[name]. Anonymous specifications are executed + # immediately and cannot be referenced later. # - # @example Require an answer equal to 42. - # # The spec - # Fix :Answer do - # it MUST equal 42 + # @example Creating a named specification for later use + # Fix :Calculator do + # on(:add, 2, 3) do + # it MUST equal 5 + # end # end # - # # A test - # Fix[:Answer].test { 42 } + # # Later in the code: + # Fix[:Calculator].test { Calculator.new } # - # @param name [String, Symbol] The constant name of the specifications. - # @yield The specifications block that defines the test requirements - # @yieldreturn [void] + # @example Creating and immediately testing an anonymous specification + # Fix do + # it MUST be_positive + # end.test { 42 } # - # @return [#test] The collection of specifications. + # @param name [String, Symbol, nil] The constant name for the specification + # @yield The specification definition block + # @yieldreturn [void] + # @return [Fix::Set] A collection of specifications ready for testing # - # @api public + # @see Fix::Builder + # @see Fix::Set + # @see Fix::Dsl def Fix(name = nil, &) - klass = ::Class.new(::Fix::Dsl) - klass.const_set(:CONTEXTS, [klass]) - klass.instance_eval(&) - ::Fix::Doc.const_set(name, klass) unless name.nil? - ::Fix::Set.new(*klass.const_get(:CONTEXTS)) + ::Fix::Builder.build(name, &) end # rubocop:enable Naming/MethodName