pax_global_header00006660000000000000000000000064151020416130014503gustar00rootroot0000000000000052 comment=6823dd1bcee8e996d3d2732e7a81fc26a2ef70b5 state_machines-activemodel-0.101.0/000077500000000000000000000000001510204161300171235ustar00rootroot00000000000000state_machines-activemodel-0.101.0/.github/000077500000000000000000000000001510204161300204635ustar00rootroot00000000000000state_machines-activemodel-0.101.0/.github/workflows/000077500000000000000000000000001510204161300225205ustar00rootroot00000000000000state_machines-activemodel-0.101.0/.github/workflows/engines.yml000066400000000000000000000011221510204161300246670ustar00rootroot00000000000000name: Exotic Ruby on: push: branches: [ master ] pull_request: branches: [ master ] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: gemfiles: - gemfiles/active_model_7.2.gemfile - gemfiles/active_model_8.0.gemfile env: BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfiles }} steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' bundler-cache: true - name: Run tests run: bundle exec rake state_machines-activemodel-0.101.0/.github/workflows/release.yml000066400000000000000000000017701510204161300246700ustar00rootroot00000000000000name: release-please on: push: branches: - master workflow_dispatch: permissions: contents: write pull-requests: write issues: write jobs: release-please: runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v4 id: release - name: Checkout if: ${{ steps.release.outputs.release_created }} uses: actions/checkout@v4 - name: Update COSS version if: ${{ steps.release.outputs.release_created }} run: | VERSION=$(grep "VERSION = " lib/state_machines/integrations/active_model/version.rb | sed "s/.*'\(.*\)'.*/\1/") sed -i "s/^version = .*/version = \"$VERSION\"/" coss.toml git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add coss.toml if ! git diff --cached --quiet; then git commit -m "chore: update COSS version to $VERSION" git push fi state_machines-activemodel-0.101.0/.github/workflows/ruby.yml000066400000000000000000000012631510204161300242260ustar00rootroot00000000000000name: Ruby on: push: branches: [ master ] pull_request: branches: [ master ] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: ruby-version: ['3.2', '3.4'] gemfiles: - gemfiles/active_model_7.2.gemfile - gemfiles/active_model_8.0.gemfile - gemfiles/active_model_8.1.gemfile env: BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfiles }} steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - name: Run tests run: bundle exec rake state_machines-activemodel-0.101.0/.gitignore000066400000000000000000000002551510204161300211150ustar00rootroot00000000000000*.gem *.rbc .bundle .config .yardoc Gemfile.lock InstalledFiles _yardoc coverage doc/ lib/bundler/man pkg rdoc spec/reports tmp *.bundle *.so *.o *.a mkmf.log .idea/ *.lock state_machines-activemodel-0.101.0/.release-please-manifest.json000066400000000000000000000000261510204161300245650ustar00rootroot00000000000000{ ".": "0.101.0" } state_machines-activemodel-0.101.0/.rubocop.yml000066400000000000000000000014111510204161300213720ustar00rootroot00000000000000AllCops: NewCops: enable TargetRubyVersion: 3.0 SuggestExtensions: false # Allow nested method definitions in tests - they're used for test setup Lint/NestedMethodDefinition: Exclude: - 'test/**/*_test.rb' # Test setup methods can be longer Metrics/MethodLength: Exclude: - 'test/**/*_test.rb' - 'test/test_helper.rb' Max: 10 # Use bracket style for percent literals Style/PercentLiteralDelimiters: PreferredDelimiters: '%w': '[]' '%W': '[]' '%i': '[]' '%I': '[]' # The save method in tests returns a boolean, it's not a predicate method Naming/PredicateMethod: Exclude: - 'test/**/*_test.rb' # In tests, we sometimes need empty initialize methods for stubbing Style/RedundantInitialize: Exclude: - 'test/**/*_test.rb'state_machines-activemodel-0.101.0/Appraisals000066400000000000000000000004051510204161300211440ustar00rootroot00000000000000# frozen_string_literal: true # ActiveModel integrations appraise 'active_model_7.2' do gem 'activemodel', '~> 7.2.0' end appraise 'active_model_8.0' do gem 'activemodel', '~> 8.0.0' end appraise 'active_model_8.1' do gem 'activemodel', '~> 8.1.0' end state_machines-activemodel-0.101.0/CHANGELOG.md000066400000000000000000000130441510204161300207360ustar00rootroot00000000000000# Changelog ## [0.101.0](https://github.com/state-machines/state_machines-activemodel/compare/state_machines-activemodel-v0.100.0...state_machines-activemodel/v0.101.0) (2025-11-03) ### Features * modernize ActiveModel integration with backward compatibility ([a0a6c5f](https://github.com/state-machines/state_machines-activemodel/commit/a0a6c5f033687913b669fbc78e0f42c782502c83)) * modernize ActiveModel integration with backward compatibility ([8b6a779](https://github.com/state-machines/state_machines-activemodel/commit/8b6a779e5683dcdef18f2e082f261c581e2a9d32)) * modernize ActiveModel integration with Ruby 3+ patterns ([6a15719](https://github.com/state-machines/state_machines-activemodel/commit/6a15719013d2a734ff33e60482e624c37022c8ba)) * modernize ActiveModel integration with Ruby 3+ patterns ([f3ee659](https://github.com/state-machines/state_machines-activemodel/commit/f3ee6592f5fa49878885eebc927844ca1ab91038)) * remove EOL version of ruby and rails ([865e3e2](https://github.com/state-machines/state_machines-activemodel/commit/865e3e2e42ef16dbe3abe99aa46e9eb4b5870543)) * reset version to 0.100.0 for proper semantic versioning ([55277f8](https://github.com/state-machines/state_machines-activemodel/commit/55277f8a1ef544bc07b41b4455d6546f9a2bece3)), closes [#49](https://github.com/state-machines/state_machines-activemodel/issues/49) * upgrade to Rails 8.1.0 stable and state_machines 0.100.4 ([#51](https://github.com/state-machines/state_machines-activemodel/issues/51)) ([eba0dd1](https://github.com/state-machines/state_machines-activemodel/commit/eba0dd10354015d10e59b6f55b2c543524dc50af)) ### Bug Fixes * prepare to release ([8d06fb0](https://github.com/state-machines/state_machines-activemodel/commit/8d06fb049c01aca1b9d45e8c01abaaceaa3f77f9)) * prepare to release 0.10.0 ([43dd535](https://github.com/state-machines/state_machines-activemodel/commit/43dd5352c16911f683e6b411e4ffd64fafeff3c4)) * preserve custom human_name for both states and events ([3e19695](https://github.com/state-machines/state_machines-activemodel/commit/3e196956f050a85e1074bc4c108eb77b27a16b07)) * preserve custom human_name for both states and events ([d7a41ba](https://github.com/state-machines/state_machines-activemodel/commit/d7a41bad420f0e999089fa87b0c00bc34f7b93f6)), closes [#37](https://github.com/state-machines/state_machines-activemodel/issues/37) [#38](https://github.com/state-machines/state_machines-activemodel/issues/38) * update tests to avoid warning syntax ([#47](https://github.com/state-machines/state_machines-activemodel/issues/47)) ([51fe6fc](https://github.com/state-machines/state_machines-activemodel/commit/51fe6fc2dc6977b40b733aa940faba4db3dc48d6)) ## [0.31.2](https://github.com/state-machines/state_machines-activemodel/compare/state_machines-activemodel/v0.31.1...state_machines-activemodel/v0.31.2) (2025-11-03) ### Features * upgrade to Rails 8.1.0 stable and state_machines 0.100.4 ([#51](https://github.com/state-machines/state_machines-activemodel/issues/51)) ([eba0dd1](https://github.com/state-machines/state_machines-activemodel/commit/eba0dd10354015d10e59b6f55b2c543524dc50af)) ## [0.31.1](https://github.com/state-machines/state_machines-activemodel/compare/state_machines-activemodel/v0.31.0...state_machines-activemodel/v0.31.1) (2025-07-25) ### Bug Fixes * update tests to avoid warning syntax ([#47](https://github.com/state-machines/state_machines-activemodel/issues/47)) ([51fe6fc](https://github.com/state-machines/state_machines-activemodel/commit/51fe6fc2dc6977b40b733aa940faba4db3dc48d6)) ## [0.31.0](https://github.com/state-machines/state_machines-activemodel/compare/state_machines-activemodel/v0.10.0...state_machines-activemodel/v0.31.0) (2025-06-29) ### Features * modernize ActiveModel integration with backward compatibility ([a0a6c5f](https://github.com/state-machines/state_machines-activemodel/commit/a0a6c5f033687913b669fbc78e0f42c782502c83)) * modernize ActiveModel integration with backward compatibility ([8b6a779](https://github.com/state-machines/state_machines-activemodel/commit/8b6a779e5683dcdef18f2e082f261c581e2a9d32)) * modernize ActiveModel integration with Ruby 3+ patterns ([6a15719](https://github.com/state-machines/state_machines-activemodel/commit/6a15719013d2a734ff33e60482e624c37022c8ba)) * modernize ActiveModel integration with Ruby 3+ patterns ([f3ee659](https://github.com/state-machines/state_machines-activemodel/commit/f3ee6592f5fa49878885eebc927844ca1ab91038)) ### Bug Fixes * preserve custom human_name for both states and events ([3e19695](https://github.com/state-machines/state_machines-activemodel/commit/3e196956f050a85e1074bc4c108eb77b27a16b07)) * preserve custom human_name for both states and events ([d7a41ba](https://github.com/state-machines/state_machines-activemodel/commit/d7a41bad420f0e999089fa87b0c00bc34f7b93f6)), closes [#37](https://github.com/state-machines/state_machines-activemodel/issues/37) [#38](https://github.com/state-machines/state_machines-activemodel/issues/38) ## [0.10.0](https://github.com/state-machines/state_machines-activemodel/compare/state_machines-activemodel-v0.9.0...state_machines-activemodel/v0.10.0) (2025-06-12) ### Features * remove EOL version of ruby and rails ([865e3e2](https://github.com/state-machines/state_machines-activemodel/commit/865e3e2e42ef16dbe3abe99aa46e9eb4b5870543)) ### Bug Fixes * prepare to release ([8d06fb0](https://github.com/state-machines/state_machines-activemodel/commit/8d06fb049c01aca1b9d45e8c01abaaceaa3f77f9)) * prepare to release 0.10.0 ([43dd535](https://github.com/state-machines/state_machines-activemodel/commit/43dd5352c16911f683e6b411e4ffd64fafeff3c4)) state_machines-activemodel-0.101.0/Gemfile000066400000000000000000000003031510204161300204120ustar00rootroot00000000000000# frozen_string_literal: true source 'https://rubygems.org' # Specify your gem's dependencies in state_machine2_activemodel.gemspec gemspec platforms :mri do gem 'debug' end gem 'rubocop' state_machines-activemodel-0.101.0/LICENSE.txt000066400000000000000000000021351510204161300207470ustar00rootroot00000000000000Copyright (c) 2006-2012 Aaron Pfeifer Copyright (c) 2014-2023 Abdelkader Boudih MIT License 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. state_machines-activemodel-0.101.0/README.md000066400000000000000000000030731510204161300204050ustar00rootroot00000000000000![Build Status](https://github.com/state-machines/state_machines-activemodel/actions/workflows/ruby.yml/badge.svg) # StateMachines ActiveModel Integration The ActiveModel integration is useful for both standalone usage and for providing the base implementation for ORMs which implement the ActiveModel API. This integration adds support for validation errors and dirty attribute tracking. ## Installation Add this line to your application's Gemfile: gem 'state_machines-activemodel' And then execute: $ bundle Or install it yourself as: $ gem install state_machines-activemodel ## Dependencies Active Model 7.1+ ## Usage ```ruby class Vehicle include ActiveModel::Dirty include ActiveModel::Validations attr_accessor :state define_attribute_methods [:state] state_machine initial: :parked do before_transition parked: any - :parked, do: :put_on_seatbelt after_transition any: :parked do |vehicle, transition| vehicle.seatbelt = 'off' end around_transition :benchmark event ignite: do transition parked: :idling end state :first_gear, :second_gear do validates :seatbelt_on, presence: true end end def put_on_seatbelt ... end def benchmark ... yield ... end end ``` ## Contributing 1. Fork it ( https://github.com/state-machines/state_machines-activemodel/fork ) 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 a new Pull Request state_machines-activemodel-0.101.0/Rakefile000066400000000000000000000003451510204161300205720ustar00rootroot00000000000000# frozen_string_literal: true require 'bundler/gem_tasks' require 'rake/testtask' Rake::TestTask.new do |t| t.libs << 'test' t.test_files = FileList['test/*_test.rb'] end desc 'Default: run all tests.' task default: :test state_machines-activemodel-0.101.0/bin/000077500000000000000000000000001510204161300176735ustar00rootroot00000000000000state_machines-activemodel-0.101.0/bin/console000077500000000000000000000006071510204161300212660ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true require "bundler/setup" require "state_machines-activemodel" # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. # (If you use this, don't forget to add pry to your Gemfile!) # require "pry" # Pry.start require "irb" IRB.start(__FILE__) state_machines-activemodel-0.101.0/bin/setup000077500000000000000000000002031510204161300207540ustar00rootroot00000000000000#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' set -vx bundle install # Do any other automated setup that you need to do here state_machines-activemodel-0.101.0/coss.toml000066400000000000000000000126101510204161300207670ustar00rootroot00000000000000# COSS Metadata Template v0.0.2 # This is the official COSS (Contriboss Open Source Standard) specification template # Copy this file to your project root as coss.toml and customize for your project ########################################################## # 1. Basic Project Information name = "state_machines-activemodel" version = "0.101.0" description = "Adds support for creating state machines for attributes on ActiveModel models" licenses = ["MIT"] ai_contributions = true coss_compliant = true homepage = "https://github.com/state-machines/state_machines-activemodel" keywords = [ "ruby", "state-machine", "activemodel", "rails", "validations", "callbacks", ] ########################################################## # 2. Repository and Issue Tracking repository = "https://github.com/state-machines/state_machines-activemodel" issue_tracker = "https://github.com/state-machines/state_machines-activemodel/issues" documentation = "https://github.com/state-machines/state_machines-activemodel/blob/master/README.md" security_policy = "" ########################################################## # 3. Languages, Frameworks, and Platforms languages = ["ruby"] [frameworks] rails = "7.1+" activemodel = "7.1+" supported_platforms = ["linux", "darwin", "windows"] ########################################################## # 4. Dependency Lock Files [dependency_locks] ruby = "Gemfile.lock" appraisal = "gemfiles/*.gemfile.lock" [packaging] ruby = "gem build state_machines-activemodel.gemspec" ########################################################## # 5. Maintainers and Governance maintainers = ["terminale@gmail.com"] governance = { type = "informal" } ########################################################## # 6. Linting, Formatting, and Static Analysis lint = "bundle exec rubocop" format = "bundle exec rubocop -a" static_analysis = ["bundle exec rubocop"] ########################################################## # 7. CI and Build Commands build = "bundle install" test = "rake test" test_all = "appraisal rake test" coverage = "" ########################################################## # 8. Tests and Quality Metrics [test_frameworks] ruby = "minitest" appraisal = true # Tests against multiple Rails versions test_report_format = "minitest" coverage_threshold = 0 ########################################################## # 9. Commit Guidelines and Formats commit_message_format = "" ########################################################## # 10. Release and Changelog changelog = "CHANGELOG.md" release_tag_pattern = "v{version}" ########################################################## # 11. Badges and Integrations (Optional) [badges] ci = "https://github.com/state-machines/state_machines-activemodel/actions/workflows/ruby.yml/badge.svg" coverage = "" license_badge = "" ########################################################## # 12. Optional Miscellaneous Fields chat = "" support = { type = "github", contact = "https://github.com/state-machines/state_machines-activemodel/issues" } apidocs = "" ########################################################## # 13. Environment and Runtime Info [environments] ruby = "3.1+" rails = "7.1+" ########################################################## # 15. Project Classification project_type = "library" maturity = "stable" audience = ["developers", "ruby-developers", "rails-developers"] ########################################################## # 16. Localization / Internationalization [i18n] default_locale = "en" supported_locales = ["en"] translation_files = "lib/state_machines/integrations/active_model/locale.rb" ########################################################## # 17. Contribution Automation [contribution_tooling] dependabot = false precommit_hooks = false ai_review = "disabled" codeowners = "" ########################################################## # 18. Security Scanning and SBOM [security] sbom = "" vulnerability_scanner = "" license_compliance_tool = "" ########################################################## # 19. Documentation Quality Flags [docs] coverage = 0 style = "" ai_summary_enabled = false ########################################################## # 20. Submodules and Component References [dependencies] # Core dependency state_machines = ">= 0.31.0" activemodel = ">= 7.1" [related_projects] # Other gems in the state_machines ecosystem state_machines = "https://github.com/state-machines/state_machines" state_machines-activerecord = "https://github.com/state-machines/state_machines-activerecord" state_machines-audit_trail = "https://github.com/state-machines/state_machines-audit_trail" state_machines-graphviz = "https://github.com/state-machines/state_machines-graphviz" state_machines-yard = "https://github.com/state-machines/state_machines-yard" ########################################################## # 21. Integration-Specific Information [activemodel_integration] features = [ "state validations", "transition callbacks", "dirty attribute tracking", "i18n support", "mass assignment protection", "validation errors on invalid transitions", ] tested_versions = ["7.1", "7.2", "8.0", "edge"] ########################################################## # 22. Testing Commands [testing] specific_file = "ruby -Itest test/path/to/test.rb" specific_test = "ruby -Itest test/path/to/test.rb:line_number" appraisal_setup = "appraisal install" rails_version_test = "appraisal rails-7-1 rake test" state_machines-activemodel-0.101.0/gemfiles/000077500000000000000000000000001510204161300207165ustar00rootroot00000000000000state_machines-activemodel-0.101.0/gemfiles/active_model_7.2.gemfile000066400000000000000000000002551510204161300252730ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "rubocop" gem "activemodel", "~> 7.2.0" platforms :mri do gem "debug" end gemspec path: "../" state_machines-activemodel-0.101.0/gemfiles/active_model_8.0.gemfile000066400000000000000000000002551510204161300252720ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "rubocop" gem "activemodel", "~> 8.0.0" platforms :mri do gem "debug" end gemspec path: "../" state_machines-activemodel-0.101.0/gemfiles/active_model_8.1.gemfile000066400000000000000000000002551510204161300252730ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "rubocop" gem "activemodel", "~> 8.1.0" platforms :mri do gem "debug" end gemspec path: "../" state_machines-activemodel-0.101.0/lib/000077500000000000000000000000001510204161300176715ustar00rootroot00000000000000state_machines-activemodel-0.101.0/lib/state_machines-activemodel.rb000066400000000000000000000003641510204161300255020ustar00rootroot00000000000000# frozen_string_literal: true require 'active_support' require 'state_machines/integrations/active_model' ActiveSupport.on_load(:i18n) do I18n.load_path << File.expand_path('state_machines/integrations/active_model/locale.rb', __dir__) end state_machines-activemodel-0.101.0/lib/state_machines/000077500000000000000000000000001510204161300226605ustar00rootroot00000000000000state_machines-activemodel-0.101.0/lib/state_machines/integrations/000077500000000000000000000000001510204161300253665ustar00rootroot00000000000000state_machines-activemodel-0.101.0/lib/state_machines/integrations/active_model.rb000066400000000000000000000433031510204161300303510ustar00rootroot00000000000000# frozen_string_literal: true require 'active_model' require 'active_support/core_ext/hash/keys' require 'active_support/core_ext/module/attribute_accessors' require 'state_machines' require 'state_machines/integrations/base' require 'state_machines/integrations/active_model/version' module StateMachines module Integrations # :nodoc: # Adds support for integrating state machines with ActiveModel classes. # # == Examples # # If using ActiveModel directly within your class, then any one of the # following features need to be included in order for the integration to be # detected: # * ActiveModel::Validations # # Below is an example of a simple state machine defined within an # ActiveModel class: # # class Vehicle # include ActiveModel::Validations # # attr_accessor :state # define_attribute_methods [:state] # # state_machine initial: :parked do # event :ignite do # transition parked: :idling # end # end # end # # The examples in the sections below will use the above class as a # reference. # # == Actions # # By default, no action will be invoked when a state is transitioned. This # means that if you want to save changes when transitioning, you must # define the action yourself like so: # # class Vehicle # include ActiveModel::Validations # attr_accessor :state # # state_machine action: :save do # ... # end # # def save # # Save changes # end # end # # == Validations # # As mentioned in StateMachine::Machine#state, you can define behaviors, # like validations, that only execute for certain states. One *important* # caveat here is that, due to a constraint in ActiveModel's validation # framework, custom validators will not work as expected when defined to run # in multiple states. For example: # # class Vehicle # include ActiveModel::Validations # # state_machine do # ... # state :first_gear, :second_gear do # validate :speed_is_legal # end # end # end # # In this case, the :speed_is_legal validation will only get run # for the :second_gear state. To avoid this, you can define your # custom validation like so: # # class Vehicle # include ActiveModel::Validations # # state_machine do # ... # state :first_gear, :second_gear do # validate { |vehicle| vehicle.speed_is_legal } # end # end # end # # == Validation errors # # In order to hook in validation support for your model, the # ActiveModel::Validations feature must be included. If this is included # and an event fails to successfully fire because there are no matching # transitions for the object, a validation error is added to the object's # state attribute to help in determining why it failed. # # For example, # # vehicle = Vehicle.new # vehicle.ignite # => false # vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""] # # In addition, if you're using the ignite! version of the event, # then the failure reason (such as the current validation errors) will be # included in the exception that gets raised when the event fails. For # example, assuming there's a validation on a field called +name+ on the class: # # vehicle = Vehicle.new # vehicle.ignite! # => StateMachine::InvalidTransition: Cannot transition state via :ignite from :parked (Reason(s): Name cannot be blank) # # === Security implications # # Beware that public event attributes mean that events can be fired # whenever mass-assignment is being used. If you want to prevent malicious # users from tampering with events through URLs / forms, the attribute # should be protected using Strong Parameters in your controllers: # # class Vehicle # attr_accessor :state # # state_machine do # ... # end # end # # # In your controller # def vehicle_params # params.require(:vehicle).permit(:attribute1, :attribute2) # Exclude :state_event # end # # If you want to only have *some* events be able to fire via mass-assignment, # you can build two state machines (one private and one public) like so: # # class Vehicle # attr_accessor :state # # state_machine do # # Define private events here # end # # # Public machine targets the same state as the private machine # state_machine :public_state, attribute: :state do # # Define public events here # end # end # # # In your controller # def vehicle_params # # Only permit events from the public state machine # params.require(:vehicle).permit(:attribute1, :attribute2, :public_state_event) # # The private state_event is not permitted # end # # == Callbacks # # All before/after transition callbacks defined for ActiveModel models # behave in the same way that other ActiveSupport callbacks behave. The # object involved in the transition is passed in as an argument. # # For example, # # class Vehicle # include ActiveModel::Validations # attr_accessor :state # # state_machine initial: :parked do # before_transition any => :idling do |vehicle| # vehicle.put_on_seatbelt # end # # before_transition do |vehicle, transition| # # log message # end # # event :ignite do # transition parked: :idling # end # end # # def put_on_seatbelt # ... # end # end # # Note, also, that the transition can be accessed by simply defining # additional arguments in the callback block. # # == Internationalization # # Any error message that is generated from performing invalid transitions # can be localized. The following default translations are used: # # en: # activemodel: # errors: # messages: # invalid: "is invalid" # # %{value} = attribute value, %{state} = Human state name # invalid_event: "cannot transition when %{state}" # # %{value} = attribute value, %{event} = Human event name, %{state} = Human current state name # invalid_transition: "cannot transition via %{event}" # # You can override these for a specific model like so: # # en: # activemodel: # errors: # models: # user: # invalid: "is not valid" # # In addition to the above, you can also provide translations for the # various states / events in each state machine. Using the Vehicle example, # state translations will be looked for using the following keys, where # +model_name+ = "vehicle", +machine_name+ = "state" and +state_name+ = "parked": # * activemodel.state_machines.#{model_name}.#{machine_name}.states.#{state_name} # * activemodel.state_machines.#{model_name}.states.#{state_name} # * activemodel.state_machines.#{machine_name}.states.#{state_name} # * activemodel.state_machines.states.#{state_name} # # Event translations will be looked for using the following keys, where # +model_name+ = "vehicle", +machine_name+ = "state" and +event_name+ = "ignite": # * activemodel.state_machines.#{model_name}.#{machine_name}.events.#{event_name} # * activemodel.state_machines.#{model_name}.events.#{event_name} # * activemodel.state_machines.#{machine_name}.events.#{event_name} # * activemodel.state_machines.events.#{event_name} # # An example translation configuration might look like so: # # es: # activemodel: # state_machines: # states: # parked: 'estacionado' # events: # park: 'estacionarse' # # == Dirty Attribute Tracking # # When using the ActiveModel::Dirty extension, your model will keep track of # any changes that are made to attributes. Depending on your ORM, an object # will only be saved when there are attributes that have changed on the # object. When integrating with state_machine, typically the +state+ field # will be marked as dirty after a transition occurs. In some situations, # however, this isn't the case. # # If you define loopback transitions in your state machine, the value for # the machine's attribute (e.g. state) will not change. Unless you explicitly # indicate so, this means that your object won't persist anything on a # loopback. For example: # # class Vehicle # include ActiveModel::Validations # include ActiveModel::Dirty # attr_accessor :state # # state_machine initial: :parked do # event :park do # transition parked: :parked, ... # end # end # end # # If, instead, you'd like your object to always persist regardless of # whether the value actually changed, you can do so by using the # #{attribute}_will_change! helpers or defining a +before_transition+ # callback that actually changes an attribute on the model. For example: # # class Vehicle # ... # state_machine initial: :parked do # before_transition all => same do |vehicle| # vehicle.state_will_change! # # # Alternative solution, updating timestamp # # vehicle.updated_at = Time.current # end # end # end # # == Creating new integrations # # If you want to integrate state_machine with an ORM that implements parts # or all of the ActiveModel API, only the machine defaults need to be # specified. Otherwise, the implementation is similar to any other # integration. # # For example, # # module StateMachine::Integrations::MyORM # include ActiveModel # # mattr_accessor(:defaults) { { action: :persist } } # # def self.matches?(klass) # defined?(::MyORM::Base) && klass <= ::MyORM::Base # end # # protected # # def runs_validations_on_action? # action == :persist # end # end # # If you wish to implement other features, such as attribute initialization # with protected attributes, named scopes, or database transactions, you # must add these independent of the ActiveModel integration. See the # ActiveRecord implementation for examples of these customizations. module ActiveModel include Base @defaults = {} # Classes that include ActiveModel::Validations # will automatically use the ActiveModel integration. def self.matching_ancestors [::ActiveModel, ::ActiveModel::Validations] end # Adds a validation error to the given object def invalidate(object, attribute, message, values = []) return unless supports_validations? attribute = self.attribute(attribute) options = values.to_h default_options = default_error_message_options(object, attribute, message) object.errors.add(attribute, message, **options, **default_options) end # Describes the current validation errors on the given object. If none # are specific, then the default error is interpeted as a "halt". def errors_for(object) object.errors.empty? ? 'Transition halted' : object.errors.full_messages.join(', ') end # Resets any errors previously added when invalidating the given object def reset(object) object.errors.clear if supports_validations? end # Runs state events around the object's validation process def around_validation(object, &) object.class.state_machines.transitions(object, action, after: false).perform(&) end protected def define_state_initializer define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1 def initialize(params = nil, **kwargs) # Support both positional hash and keyword arguments attrs = params.nil? ? kwargs : params #{' '} attrs.transform_keys! do |key| self.class.attribute_aliases[key.to_s] || key.to_s end if self.class.respond_to?(:attribute_aliases) # Call super with the appropriate arguments based on what we received self.class.state_machines.initialize_states(self, {}, attrs) do if params super(params) else super(**kwargs) end end end END_EVAL end # Whether validations are supported in the integration. Only true if # the ActiveModel feature is enabled on the owner class. def supports_validations? defined?(::ActiveModel::Validations) && owner_class <= ::ActiveModel::Validations end # Do validations run when the action configured this machine is # invoked? This is used to determine whether to fire off attribute-based # event transitions when the action is run. def runs_validations_on_action? false end # Gets the terminator to use for callbacks def callback_terminator @callback_terminator ||= ->(result) { result == false } end # Determines the base scope to use when looking up translations def i18n_scope(klass) klass.i18n_scope end # The default options to use when generating messages for validation # errors def default_error_message_options(_object, _attribute, message) { message: @messages[message] } end # Translates the given key / value combo. Translation keys are looked # up in the following order: # * #{i18n_scope}.state_machines.#{model_name}.#{machine_name}.#{plural_key}.#{value} # * #{i18n_scope}.state_machines.#{model_name}.#{plural_key}.#{value} # * #{i18n_scope}.state_machines.#{machine_name}.#{plural_key}.#{value} # * #{i18n_scope}.state_machines.#{plural_key}.#{value} # # If no keys are found, then the humanized value will be the fallback. def translate(klass, key, value) ancestors = ancestors_for(klass) group = key.to_s.pluralize value = value ? value.to_s : 'nil' # Generate all possible translation keys translations = ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{name}.#{group}.#{value}" } translations.concat(ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{group}.#{value}" }) translations.push(:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase) I18n.translate(translations.shift, default: translations, scope: [i18n_scope(klass), :state_machines]) end # Build a list of ancestors for the given class to use when # determining which localization key to use for a particular string. def ancestors_for(klass) klass.lookup_ancestors end # Skips defining reader/writer methods since this is done automatically def define_state_accessor name = self.name return unless supports_validations? owner_class.validates_each(attribute) do |object| machine = object.class.state_machine(name) machine.invalidate(object, :state, :invalid) unless machine.states.match(object) end end # Adds hooks into validation for automatically firing events def define_action_helpers super define_validation_hook if runs_validations_on_action? end # Hooks into validations by defining around callbacks for the # :validation event def define_validation_hook owner_class.set_callback(:validation, :around, self, prepend: true) end # Creates a new callback in the callback chain, always inserting it # before the default Observer callbacks that were created after # initialization. def add_callback(type, options, &) options[:terminator] = callback_terminator super end # Configures new states with the built-in humanize scheme def add_states(*) super.each do |new_state| # Only set the translation lambda if human_name is the default auto-generated value # This preserves user-specified human names while still applying translations for defaults default_human_name = new_state.name ? new_state.name.to_s.tr('_', ' ') : 'nil' if new_state.human_name == default_human_name new_state.human_name = ->(state, klass) { translate(klass, :state, state.name) } end end end # Configures new event with the built-in humanize scheme def add_events(*) super.each do |new_event| # Only set the translation lambda if human_name is the default auto-generated value # This preserves user-specified human names while still applying translations for defaults default_human_name = new_event.name ? new_event.name.to_s.tr('_', ' ') : 'nil' if new_event.human_name == default_human_name new_event.human_name = ->(event, klass) { translate(klass, :event, event.name) } end end end end register(ActiveModel) end end state_machines-activemodel-0.101.0/lib/state_machines/integrations/active_model/000077500000000000000000000000001510204161300300215ustar00rootroot00000000000000state_machines-activemodel-0.101.0/lib/state_machines/integrations/active_model/locale.rb000066400000000000000000000011211510204161300316000ustar00rootroot00000000000000# frozen_string_literal: true # Use lazy evaluation to avoid circular dependencies with frozen default_messages # This ensures messages can be updated after gem loading while maintaining thread safety { en: { activemodel: { errors: { messages: { invalid: lambda { |*| StateMachines::Machine.default_messages[:invalid] }, invalid_event: lambda { |*| StateMachines::Machine.default_messages[:invalid_event] % ['%{state}'] }, invalid_transition: lambda { |*| StateMachines::Machine.default_messages[:invalid_transition] % ['%{event}'] } } } } } } state_machines-activemodel-0.101.0/lib/state_machines/integrations/active_model/version.rb000066400000000000000000000002151510204161300320310ustar00rootroot00000000000000# frozen_string_literal: true module StateMachines module Integrations module ActiveModel VERSION = '0.101.0' end end end state_machines-activemodel-0.101.0/release-please-config.json000066400000000000000000000003721510204161300241520ustar00rootroot00000000000000{ "packages": { ".": { "release-type": "ruby", "package-name": "state_machines-activemodel", "version-file": "lib/state_machines/integrations/active_model/version.rb", "bump-patch-for-minor-pre-major": true } } } state_machines-activemodel-0.101.0/state_machines-activemodel.gemspec000066400000000000000000000023521510204161300257530ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'lib/state_machines/integrations/active_model/version' Gem::Specification.new do |spec| spec.name = 'state_machines-activemodel' spec.version = StateMachines::Integrations::ActiveModel::VERSION spec.authors = ['Abdelkader Boudih', 'Aaron Pfeifer'] spec.email = %w(terminale@gmail.com aaron@pluginaweek.org) spec.summary = 'ActiveModel integration for State Machines' spec.description = 'Adds support for creating state machines for attributes on ActiveModel' spec.homepage = 'https://github.com/state-machines/state_machines-activemodel' spec.license = 'MIT' spec.files = Dir.glob('{lib}/**/*') + %w(LICENSE.txt README.md) spec.test_files = Dir.glob('test/**/{*_test,test_*}.rb') spec.require_paths = ['lib'] spec.required_ruby_version = '>= 3.2.0' spec.add_dependency 'state_machines', '>= 0.100.4' spec.add_dependency 'activemodel', '>= 7.2' spec.add_development_dependency 'bundler', '>= 1.6' spec.add_development_dependency 'rake', '>= 10' spec.add_development_dependency 'appraisal', '>= 1' spec.add_development_dependency 'minitest', '~> 5.4' spec.add_development_dependency 'minitest-reporters' end state_machines-activemodel-0.101.0/test/000077500000000000000000000000001510204161300201025ustar00rootroot00000000000000state_machines-activemodel-0.101.0/test/event_human_name_test.rb000066400000000000000000000136451510204161300250100ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class EventHumanNameTest < BaseTestCase def setup @model = new_model do include ActiveModel::Validations attr_accessor :status end end def test_should_allow_custom_human_name_on_event machine = StateMachines::Machine.new(@model, :status, initial: :parked) do event :start, human_name: 'Start Engine' do transition parked: :running end event :stop do transition running: :parked end event :pause, human_name: 'Temporarily Pause' do transition running: :paused end end assert_equal 'Start Engine', machine.events[:start].human_name(@model) assert_equal 'Temporarily Pause', machine.events[:pause].human_name(@model) end def test_should_not_override_custom_event_human_name_with_translation # Set up I18n translations I18n.backend.store_translations(:en, { activemodel: { state_machines: { events: { ignite: 'Translation for Ignite', park: 'Translation for Park', repair: 'Translation for Repair' } } } }) machine = StateMachines::Machine.new(@model, :status, initial: :parked) do event :ignite, human_name: 'Custom Ignition' do transition parked: :idling end event :park do transition idling: :parked end event :repair, human_name: 'Custom Repair Process' do transition any => :parked end end # Custom human names should be preserved assert_equal 'Custom Ignition', machine.events[:ignite].human_name(@model) assert_equal 'Custom Repair Process', machine.events[:repair].human_name(@model) # Event without custom human_name should use translation assert_equal 'Translation for Park', machine.events[:park].human_name(@model) end def test_should_allow_custom_event_human_name_as_string machine = StateMachines::Machine.new(@model, :status) do event :activate, human_name: 'Turn On' end assert_equal 'Turn On', machine.events[:activate].human_name(@model) end def test_should_allow_custom_event_human_name_as_lambda machine = StateMachines::Machine.new(@model, :status) do event :process, human_name: ->(event, klass) { "#{klass.name}: #{event.name.to_s.capitalize} Action" } end assert_equal 'Foo: Process Action', machine.events[:process].human_name(@model) end def test_should_use_default_translation_when_no_custom_event_human_name machine = StateMachines::Machine.new(@model, :status) do event :idle end # Should fall back to humanized version when no translation exists assert_equal 'idle', machine.events[:idle].human_name(@model) end def test_should_handle_nil_event_human_name machine = StateMachines::Machine.new(@model, :status) do event :wait end # Explicitly set to nil machine.events[:wait].human_name = nil # When human_name is nil, Event#human_name returns nil assert_nil machine.events[:wait].human_name(@model) end def test_should_preserve_event_human_name_through_multiple_definitions machine = StateMachines::Machine.new(@model, :status, initial: :draft) # First define event with custom human name machine.event :publish, human_name: 'Make Public' do transition draft: :published end # Redefine the same event (this should not override the human_name) machine.event :publish do transition pending: :published end assert_equal 'Make Public', machine.events[:publish].human_name(@model) end def test_should_work_with_state_machine_helper_method @model.class_eval do state_machine :status, initial: :pending do event :approve, human_name: 'Grant Approval' do transition pending: :approved end event :reject do transition pending: :rejected end end end machine = @model.state_machine(:status) assert_equal 'Grant Approval', machine.events[:approve].human_name(@model) end def test_should_handle_complex_i18n_lookup_with_custom_event_human_name # Set up complex I18n structure I18n.backend.store_translations(:en, { activemodel: { state_machines: { foo: { status: { events: { submit: 'Model Specific Submit' } } }, status: { events: { submit: 'Machine Specific Submit' } }, events: { submit: 'Generic Submit' } } } }) machine = StateMachines::Machine.new(@model, :status) do event :submit, human_name: 'Send for Review' do transition draft: :pending end end # Should use the custom human_name, not any of the I18n translations assert_equal 'Send for Review', machine.events[:submit].human_name(@model) end def teardown # Clear I18n translations after each test I18n.backend.reload! end end state_machines-activemodel-0.101.0/test/files/000077500000000000000000000000001510204161300212045ustar00rootroot00000000000000state_machines-activemodel-0.101.0/test/files/en.yml000066400000000000000000000001341510204161300223270ustar00rootroot00000000000000en: activemodel: errors: messages: invalid_transition: "cannot %{event}"state_machines-activemodel-0.101.0/test/human_name_preservation_test.rb000066400000000000000000000045251510204161300264050ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class HumanNamePreservationTest < BaseTestCase def setup @model = new_model do include ActiveModel::Validations attr_accessor :status end end def test_should_preserve_custom_state_human_name_when_using_activemodel_integration # This test specifically verifies that PR #38's fix works: # Using ||= instead of = in add_states method @model.class_eval do state_machine :status, initial: :pending do # Define a state with a custom human_name state :pending, human_name: 'My Custom Pending' state :approved end end machine = @model.state_machine(:status) # The custom human_name should be preserved, not overwritten by the integration assert_equal 'My Custom Pending', machine.states[:pending].human_name(@model) end def test_should_preserve_custom_event_human_name_when_using_activemodel_integration # This test verifies our additional fix for events: # Using ||= instead of = in add_events method @model.class_eval do state_machine :status, initial: :pending do event :approve, human_name: 'Grant Authorization' do transition pending: :approved end event :reject do transition pending: :rejected end end end machine = @model.state_machine(:status) # The custom human_name should be preserved, not overwritten by the integration assert_equal 'Grant Authorization', machine.events[:approve].human_name(@model) end def test_regression_issue_37_hard_coded_human_name_preserved # This is the exact regression test for issue #37 # "Hard-coded human_name is being overwritten" @model.class_eval do state_machine :status do state :pending, human_name: 'Pending Approval' state :active, human_name: 'Active State' event :activate, human_name: 'Activate Now' do transition pending: :active end end end machine = @model.state_machine(:status) # Both states and events should preserve their hard-coded human names assert_equal 'Pending Approval', machine.states[:pending].human_name(@model) assert_equal 'Active State', machine.states[:active].human_name(@model) assert_equal 'Activate Now', machine.events[:activate].human_name(@model) end end state_machines-activemodel-0.101.0/test/integration_test.rb000066400000000000000000000016411510204161300240130ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class IntegrationTest < BaseTestCase def test_should_be_registered assert_includes StateMachines::Integrations.list, StateMachines::Integrations::ActiveModel end def test_should_register_one_integration assert_equal 1, StateMachines::Integrations.list.size end def test_should_have_an_integration_name assert_equal :active_model, StateMachines::Integrations::ActiveModel.integration_name end def test_should_match_if_class_includes_validations_feature assert StateMachines::Integrations::ActiveModel.matches?(new_model { include ActiveModel::Validations }) end def test_should_not_match_if_class_does_not_include_active_model_features refute StateMachines::Integrations::ActiveModel.matches?(new_plain_model) end def test_should_have_no_defaults assert_equal({}, StateMachines::Integrations::ActiveModel.defaults) end end state_machines-activemodel-0.101.0/test/machine_by_default_test.rb000066400000000000000000000011241510204161300252660ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineByDefaultTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, integration: :active_model) end def test_should_not_have_action assert_nil @machine.action end def test_should_use_transactions assert_equal true, @machine.use_transactions end def test_should_not_have_any_before_callbacks assert_equal 0, @machine.callbacks[:before].size end def test_should_not_have_any_after_callbacks assert_equal 0, @machine.callbacks[:after].size end end state_machines-activemodel-0.101.0/test/machine_errors_test.rb000066400000000000000000000011731510204161300244700ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineErrorsTest < BaseTestCase def setup @model = new_model { include ActiveModel::Validations } @machine = StateMachines::Machine.new(@model) @record = @model.new end def test_should_be_able_to_describe_current_errors @record.errors.add(:id, 'cannot be blank') @record.errors.add(:state, 'is invalid') assert_equal ['Id cannot be blank', 'State is invalid'], @machine.errors_for(@record).split(', ').sort end def test_should_describe_as_halted_with_no_errors assert_equal 'Transition halted', @machine.errors_for(@record) end end state_machines-activemodel-0.101.0/test/machine_initialization_compatibility_test.rb000066400000000000000000000032151510204161300311330ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class MachineInitializationCompatibilityTest < BaseTestCase def setup @model = new_model do include ActiveModel::Validations end @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.state :parked, :idling @machine.event :ignite end def test_should_accept_positional_hash_argument record = @model.new({ state: 'idling' }) assert_equal 'idling', record.state end def test_should_accept_keyword_arguments record = @model.new(state: 'idling') assert_equal 'idling', record.state end def test_should_accept_empty_initialization record = @model.new assert_equal 'parked', record.state end def test_should_handle_attribute_aliases @model.class_eval do alias_attribute :status, :state end record = @model.new(status: 'idling') assert_equal 'idling', record.state end def test_should_prefer_positional_hash_over_keywords_when_both_present # If someone accidentally provides both, positional takes precedence record = @model.new({ state: 'idling' }, state: 'parked') assert_equal 'idling', record.state end def test_should_handle_empty_positional_hash # Empty hash should still be treated as positional argument record = @model.new({}) assert_equal 'parked', record.state # Gets default initial state end def test_should_use_keywords_when_empty_hash_and_keywords_present # With the fix, keywords are ignored even with empty positional hash record = @model.new({}, state: 'idling') assert_equal 'parked', record.state # Empty hash takes precedence end end state_machines-activemodel-0.101.0/test/machine_multiple_test.rb000066400000000000000000000010561510204161300250070ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineMultipleTest < BaseTestCase def setup @model = new_model do attribute :status, :string end @state_machine = StateMachines::Machine.new(@model, initial: :parked, integration: :active_model) @status_machine = StateMachines::Machine.new(@model, :status, initial: :idling, integration: :active_model) end def test_should_should_initialize_each_state record = @model.new assert_equal 'parked', record.state assert_equal 'idling', record.status end end state_machines-activemodel-0.101.0/test/machine_with_callbacks_test.rb000066400000000000000000000063551510204161300261350ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithCallbacksTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked, integration: :active_model) @machine.other_states :idling @machine.event :ignite @record = @model.new(state: 'parked') @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) end def test_should_run_before_callbacks called = false @machine.before_transition { called = true } @transition.perform assert called end def test_should_pass_record_to_before_callbacks_with_one_argument record = nil @machine.before_transition { |arg| record = arg } @transition.perform assert_equal @record, record end def test_should_pass_record_and_transition_to_before_callbacks_with_multiple_arguments callback_args = nil @machine.before_transition { |*args| callback_args = args } @transition.perform assert_equal [@record, @transition], callback_args end def test_should_run_before_callbacks_outside_the_context_of_the_record context = nil @machine.before_transition { context = self } @transition.perform assert_equal self, context end def test_should_run_after_callbacks called = false @machine.after_transition { called = true } @transition.perform assert called end def test_should_pass_record_to_after_callbacks_with_one_argument record = nil @machine.after_transition { |arg| record = arg } @transition.perform assert_equal @record, record end def test_should_pass_record_and_transition_to_after_callbacks_with_multiple_arguments callback_args = nil @machine.after_transition { |*args| callback_args = args } @transition.perform assert_equal [@record, @transition], callback_args end def test_should_run_after_callbacks_outside_the_context_of_the_record context = nil @machine.after_transition { context = self } @transition.perform assert_equal self, context end def test_should_run_around_callbacks before_called = false after_called = false ensure_called = 0 @machine.around_transition do |block| before_called = true begin block.call ensure ensure_called += 1 end after_called = true end @transition.perform assert before_called assert after_called assert_equal ensure_called, 1 end def test_should_include_transition_states_in_known_states @machine.before_transition to: :first_gear, do: lambda {} assert_equal [:parked, :idling, :first_gear], @machine.states.map { |state| state.name } end def test_should_allow_symbolic_callbacks callback_args = nil klass = class << @record self end klass.send(:define_method, :after_ignite) do |*args| callback_args = args end @machine.before_transition(:after_ignite) @transition.perform assert_equal [@transition], callback_args end def test_should_allow_string_callbacks class << @record attr_reader :callback_result end @machine.before_transition('@callback_result = [1, 2, 3]') @transition.perform assert_equal [1, 2, 3], @record.callback_result end end machine_with_dirty_attribute_and_custom_attributes_during_loopback_test.rb000066400000000000000000000014351510204161300372530ustar00rootroot00000000000000state_machines-activemodel-0.101.0/test# frozen_string_literal: true require 'test_helper' class MachineWithDirtyAttributeAndCustomAttributesDuringLoopbackTest < BaseTestCase def setup @model = new_model do attribute :status, :string def save if valid? changes_applied true else false end end end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @machine.event :park @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :park, :parked, :parked) @transition.perform end def test_should_not_include_state_in_changed_attributes assert_equal [], @record.changed end def test_should_not_track_attribute_changes assert_nil @record.changes['status'] end end state_machines-activemodel-0.101.0/test/machine_with_dirty_attribute_and_state_events_test.rb000066400000000000000000000012341510204161300330310ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithDirtyAttributeAndStateEventsTest < BaseTestCase def setup @model = new_model do def save if valid? changes_applied true else false end end end @machine = StateMachines::Machine.new(@model, action: :save, initial: :parked) @machine.event :ignite @record = @model.create @record.state_event = 'ignite' end def test_should_not_include_state_in_changed_attributes assert_equal [], @record.changed end def test_should_not_track_attribute_change assert_nil @record.changes['state'] end end state_machines-activemodel-0.101.0/test/machine_with_dirty_attributes_and_custom_attribute_test.rb000066400000000000000000000020701510204161300341040ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithDirtyAttributesAndCustomAttributeTest < BaseTestCase def setup @model = new_model do attribute :status, :string def save if valid? changes_applied true else false end end end @machine = StateMachines::Machine.new(@model, :status, initial: :parked) @machine.event :ignite @machine.state :idling @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @transition.perform end def test_should_include_state_in_changed_attributes assert_equal %w[status], @record.changed end def test_should_track_attribute_change assert_equal %w[parked idling], @record.changes['status'] end def test_should_not_reset_changes_on_multiple_transitions transition = StateMachines::Transition.new(@record, @machine, :ignite, :idling, :idling) transition.perform assert_equal %w[parked idling], @record.changes['status'] end end state_machines-activemodel-0.101.0/test/machine_with_dirty_attributes_during_loopback_test.rb000066400000000000000000000013371510204161300330340ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithDirtyAttributesDuringLoopbackTest < BaseTestCase def setup @model = new_model do def save if valid? changes_applied true else false end end end @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.event :park @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :park, :parked, :parked) @transition.perform end def test_should_not_include_state_in_changed_attributes assert_equal [], @record.changed end def test_should_not_track_attribute_changes assert_nil @record.changes['state'] end end state_machines-activemodel-0.101.0/test/machine_with_dirty_attributes_test.rb000066400000000000000000000017701510204161300276130ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithDirtyAttributesTest < BaseTestCase def setup @model = new_model do def save if valid? changes_applied true else false end end end @machine = StateMachines::Machine.new(@model, initial: :parked) @machine.event :ignite @machine.state :idling @record = @model.create @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @transition.perform end def test_should_include_state_in_changed_attributes assert_equal %w[state], @record.changed end def test_should_track_attribute_change assert_equal %w[parked idling], @record.changes['state'] end def test_should_not_reset_changes_on_multiple_transitions transition = StateMachines::Transition.new(@record, @machine, :ignite, :idling, :idling) transition.perform assert_equal %w[parked idling], @record.changes['state'] end end state_machines-activemodel-0.101.0/test/machine_with_dynamic_initial_state_test.rb000066400000000000000000000006501510204161300305430ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithDynamicInitialStateTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: lambda { |_object| :parked }, integration: :active_model) @machine.state :parked end def test_should_set_initial_state_on_created_object record = @model.new assert_equal 'parked', record.state end end state_machines-activemodel-0.101.0/test/machine_with_events_test.rb000066400000000000000000000005061510204161300255120ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithEventsTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.event :shift_up end def test_should_humanize_name assert_equal 'shift up', @machine.event(:shift_up).human_name end end state_machines-activemodel-0.101.0/test/machine_with_failed_after_callbacks_test.rb000066400000000000000000000017441510204161300306170ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithFailedAfterCallbacksTest < BaseTestCase def setup @callbacks = [] @model = new_model @machine = StateMachines::Machine.new(@model, integration: :active_model) @machine.state :parked, :idling @machine.event :ignite @machine.after_transition { @callbacks << :after_1; false } @machine.after_transition { @callbacks << :after_2 } @machine.around_transition { |block| @callbacks << :around_before; block.call; @callbacks << :around_after } @record = @model.new(state: 'parked') @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @result = @transition.perform end def test_should_be_successful assert @result end def test_should_change_current_state assert_equal 'idling', @record.state end def test_should_not_run_further_after_callbacks assert_equal [:around_before, :around_after, :after_1], @callbacks end end state_machines-activemodel-0.101.0/test/machine_with_failed_before_callbacks_test.rb000066400000000000000000000020041510204161300307460ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithFailedBeforeCallbacksTest < BaseTestCase def setup @callbacks = [] @model = new_model @machine = StateMachines::Machine.new(@model, integration: :active_model) @machine.state :parked, :idling @machine.event :ignite @machine.before_transition { @callbacks << :before_1; false } @machine.before_transition { @callbacks << :before_2 } @machine.after_transition { @callbacks << :after } @machine.around_transition { |block| @callbacks << :around_before; block.call; @callbacks << :around_after } @record = @model.new(state: 'parked') @transition = StateMachines::Transition.new(@record, @machine, :ignite, :parked, :idling) @result = @transition.perform end def test_should_not_be_successful refute @result end def test_should_not_change_current_state assert_equal 'parked', @record.state end def test_should_not_run_further_callbacks assert_equal [:before_1], @callbacks end end state_machines-activemodel-0.101.0/test/machine_with_initialized_aliased_attribute_test.rb000066400000000000000000000022711510204161300322610ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithInitializedAliasedAttributeTest < BaseTestCase def test_should_match_original_attribute_value_with_attribute_methods model = new_model do include ActiveModel::AttributeMethods alias_attribute :custom_status, :state end machine = StateMachines::Machine.new(model, initial: :parked, integration: :active_model) machine.other_states(:started) record = model.new(custom_status: 'started') refute record.state?(:parked) assert record.state?(:started) end def test_should_not_match_original_attribute_value_without_attribute_methods model = new_plain_model do include ActiveModel::Model attr_accessor :state def self.alias_attribute(new_name, old_name) alias_method new_name, old_name alias_method "#{new_name}=", "#{old_name}=" end alias_attribute :custom_status, :state end machine = StateMachines::Machine.new(model, initial: :parked, integration: :active_model) machine.other_states(:started) record = model.new(custom_status: 'started') assert record.state?(:parked) refute record.state?(:started) end end state_machines-activemodel-0.101.0/test/machine_with_initialized_state_test.rb000066400000000000000000000016771510204161300277250ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithInitializedStateTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked, integration: :active_model) @machine.state :idling end def test_should_allow_nil_initial_state_when_static @machine.state nil record = @model.new(state: nil) assert_nil record.state end def test_should_allow_nil_initial_state_when_dynamic @machine.state nil @machine.initial_state = -> { :parked } record = @model.new(state: nil) assert_nil record.state end def test_should_allow_different_initial_state_when_static record = @model.new(state: 'idling') assert_equal 'idling', record.state end def test_should_allow_different_initial_state_when_dynamic @machine.initial_state = -> { :parked } record = @model.new(state: 'idling') assert_equal 'idling', record.state end end state_machines-activemodel-0.101.0/test/machine_with_internationalization_test.rb000066400000000000000000000137561510204161300304660ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' require 'i18n' class MachineWithInternationalizationTest < BaseTestCase def setup I18n.backend = I18n::Backend::Simple.new @model = new_model { include ActiveModel::Validations } end def test_should_use_defaults I18n.backend.store_translations(:en, activemodel: { errors: { messages: { invalid_transition: 'cannot %{event}' } } } ) machine = StateMachines::Machine.new(@model, action: :save) machine.state :parked, :idling machine.event :ignite record = @model.new(state: 'idling') machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot transition via "ignite"'], record.errors.full_messages end def test_should_allow_customized_error_key I18n.backend.store_translations(:en, activemodel: { errors: { messages: { bad_transition: 'cannot %{event}' } } } ) machine = StateMachines::Machine.new(@model, action: :save, messages: { invalid_transition: :bad_transition }) machine.state :parked, :idling record = @model.new record.state = 'idling' machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot ignite'], record.errors.full_messages end def test_should_allow_customized_error_string machine = StateMachines::Machine.new(@model, action: :save, messages: { invalid_transition: 'cannot %{event}' }) machine.state :parked, :idling record = @model.new(state: 'idling') machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot ignite'], record.errors.full_messages end def test_should_allow_customized_state_key_scoped_to_class_and_machine I18n.backend.store_translations(:en, activemodel: { state_machines: { :'foo' => { state: { states: { parked: 'shutdown' } } } } } ) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name(@model) end def test_should_allow_customized_state_key_scoped_to_class I18n.backend.store_translations(:en, activemodel: { state_machines: { :'foo' => { states: { parked: 'shutdown' } } } } ) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name(@model) end def test_should_allow_customized_state_key_scoped_to_machine I18n.backend.store_translations(:en, activemodel: { state_machines: { state: { states: { parked: 'shutdown' } } } } ) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name(@model) end def test_should_allow_customized_state_key_unscoped I18n.backend.store_translations(:en, activemodel: { state_machines: { states: { parked: 'shutdown' } } } ) machine = StateMachines::Machine.new(@model) machine.state :parked assert_equal 'shutdown', machine.state(:parked).human_name(@model) end def test_should_support_nil_state_key I18n.backend.store_translations(:en, activemodel: { state_machines: { states: { nil: 'empty' } } } ) machine = StateMachines::Machine.new(@model) assert_equal 'empty', machine.state(nil).human_name(@model) end def test_should_allow_customized_event_key_scoped_to_class_and_machine I18n.backend.store_translations(:en, activemodel: { state_machines: { :'foo' => { state: { events: { park: 'stop' } } } } } ) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name(@model) end def test_should_allow_customized_event_key_scoped_to_class I18n.backend.store_translations(:en, activemodel: { state_machines: { :'foo' => { events: { park: 'stop' } } } } ) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name(@model) end def test_should_allow_customized_event_key_scoped_to_machine I18n.backend.store_translations(:en, activemodel: { state_machines: { state: { events: { park: 'stop' } } } } ) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name(@model) end def test_should_allow_customized_event_key_unscoped I18n.backend.store_translations(:en, activemodel: { state_machines: { events: { park: 'stop' } } } ) machine = StateMachines::Machine.new(@model) machine.event :park assert_equal 'stop', machine.event(:park).human_name(@model) end def test_should_have_locale_once_in_load_path assert_equal 1, I18n.load_path.select { |path| path =~ %r{active_model/locale\.rb$} }.length end def test_should_prefer_other_locales_first @original_load_path = I18n.load_path I18n.backend = I18n::Backend::Simple.new I18n.load_path = [File.dirname(__FILE__) + '/files/en.yml'] machine = StateMachines::Machine.new(@model) machine.state :parked, :idling machine.event :ignite record = @model.new(state: 'idling') machine.invalidate(record, :state, :invalid_transition, [[:event, 'ignite']]) assert_equal ['State cannot ignite'], record.errors.full_messages ensure I18n.load_path = @original_load_path end end state_machines-activemodel-0.101.0/test/machine_with_model_state_attribute_test.rb000066400000000000000000000016071510204161300305740ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithModelStateAttributeTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked, integration: :active_model) @machine.other_states(:idling) @record = @model.new end def test_should_have_an_attribute_predicate assert @record.respond_to?(:state?) end def test_should_raise_exception_for_predicate_without_parameters assert_raises(ArgumentError) { @record.state? } end def test_should_return_false_for_predicate_if_does_not_match_current_value assert !@record.state?(:idling) end def test_should_return_true_for_predicate_if_matches_current_value assert @record.state?(:parked) end def test_should_raise_exception_for_predicate_if_invalid_state_specified assert_raises(IndexError) { @record.state?(:invalid) } end end state_machines-activemodel-0.101.0/test/machine_with_non_model_state_attribute_undefined_test.rb000066400000000000000000000012751510204161300334700ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithNonModelStateAttributeUndefinedTest < BaseTestCase def setup @model = new_plain_model do def initialize; end end @machine = StateMachines::Machine.new(@model, :status, initial: :parked, integration: :active_model) @machine.other_states(:idling) @record = @model.new end def test_should_not_define_a_reader_attribute_for_the_attribute assert !@record.respond_to?(:status) end def test_should_not_define_a_writer_attribute_for_the_attribute assert !@record.respond_to?(:status=) end def test_should_define_an_attribute_predicate assert @record.respond_to?(:status?) end end state_machines-activemodel-0.101.0/test/machine_with_state_driven_validations_test.rb000066400000000000000000000016041510204161300312720ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithStateDrivenValidationsTest < BaseTestCase def setup @model = new_model do include ActiveModel::Validations attr_accessor :seatbelt end @machine = StateMachines::Machine.new(@model) @machine.state :first_gear, :second_gear do validates :seatbelt, presence: true end @machine.other_states :parked end def test_should_be_valid_if_validation_fails_outside_state_scope record = @model.new(state: 'parked', seatbelt: nil) assert record.valid? end def test_should_be_invalid_if_validation_fails_within_state_scope record = @model.new(state: 'first_gear', seatbelt: nil) assert !record.valid? end def test_should_be_valid_if_validation_succeeds_within_state_scope record = @model.new(state: 'second_gear', seatbelt: true) assert record.valid? end end state_machines-activemodel-0.101.0/test/machine_with_states_test.rb000066400000000000000000000005141510204161300255100ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithStatesTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model) @machine.state :first_gear end def test_should_humanize_name assert_equal 'first gear', @machine.state(:first_gear).human_name end end state_machines-activemodel-0.101.0/test/machine_with_static_initial_state_test.rb000066400000000000000000000005671510204161300304150ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithStaticInitialStateTest < BaseTestCase def setup @model = new_model @machine = StateMachines::Machine.new(@model, initial: :parked, integration: :active_model) end def test_should_set_initial_state_on_created_object record = @model.new assert_equal 'parked', record.state end end state_machines-activemodel-0.101.0/test/machine_with_validations_and_custom_attribute_test.rb000066400000000000000000000011071510204161300330200ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithValidationsAndCustomAttributeTest < BaseTestCase def setup @model = new_model { include ActiveModel::Validations } @machine = StateMachines::Machine.new(@model, :status, attribute: :state) @machine.state :parked @record = @model.new end def test_should_add_validation_errors_to_custom_attribute @record.state = 'invalid' assert !@record.valid? assert_equal ['State is invalid'], @record.errors.full_messages @record.state = 'parked' assert @record.valid? end end state_machines-activemodel-0.101.0/test/machine_with_validations_test.rb000066400000000000000000000024431510204161300265250ustar00rootroot00000000000000# frozen_string_literal: true require 'test_helper' class MachineWithValidationsTest < BaseTestCase def setup @model = new_model { include ActiveModel::Validations } @machine = StateMachines::Machine.new(@model, action: :save) @machine.state :parked @record = @model.new end def test_should_invalidate_using_errors I18n.backend = I18n::Backend::Simple.new if Object.const_defined?(:I18n) @record.state = 'parked' @machine.invalidate(@record, :state, :invalid_transition, [[:event, 'park']]) assert_equal ['State cannot transition via "park"'], @record.errors.full_messages end def test_should_auto_prefix_custom_attributes_on_invalidation @machine.invalidate(@record, :event, :invalid) assert_equal ['State event is invalid'], @record.errors.full_messages end def test_should_clear_errors_on_reset @record.state = 'parked' @record.errors.add(:state, 'is invalid') @machine.reset(@record) assert_equal [], @record.errors.full_messages end def test_should_be_valid_if_state_is_known @record.state = 'parked' assert @record.valid? end def test_should_not_be_valid_if_state_is_unknown @record.state = 'invalid' assert !@record.valid? assert_equal ['State is invalid'], @record.errors.full_messages end end state_machines-activemodel-0.101.0/test/state_human_name_test.rb000066400000000000000000000131451510204161300250020ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'test_helper' class StateHumanNameTest < BaseTestCase def setup @model = new_model do include ActiveModel::Validations attr_accessor :status end end def test_should_allow_custom_human_name_on_state machine = StateMachines::Machine.new(@model, :status, initial: :pending) do state :pending, human_name: 'Awaiting Approval' state :approved state :rejected, human_name: 'Denied' end assert_equal 'Awaiting Approval', machine.states[:pending].human_name(@model) assert_equal 'Denied', machine.states[:rejected].human_name(@model) end def test_should_not_override_custom_human_name_with_translation # Set up I18n translations I18n.backend.store_translations(:en, { activemodel: { state_machines: { states: { pending: 'Translation for Pending', approved: 'Translation for Approved', rejected: 'Translation for Rejected' } } } }) machine = StateMachines::Machine.new(@model, :status, initial: :pending) do state :pending, human_name: 'Custom Pending Name' state :approved state :rejected, human_name: 'Custom Rejected Name' end # Custom human names should be preserved assert_equal 'Custom Pending Name', machine.states[:pending].human_name(@model) assert_equal 'Custom Rejected Name', machine.states[:rejected].human_name(@model) # State without custom human_name gets default behavior (which might not use translations in this test setup) # The key test is that custom human names are preserved, not overwritten refute_equal 'Custom Pending Name', machine.states[:approved].human_name(@model) end def test_should_allow_custom_human_name_as_string machine = StateMachines::Machine.new(@model, :status) do state :active, human_name: 'Currently Active' end assert_equal 'Currently Active', machine.states[:active].human_name(@model) end def test_should_allow_custom_human_name_as_lambda machine = StateMachines::Machine.new(@model, :status) do state :processing, human_name: ->(state, klass) { "#{klass.name} is #{state.name.to_s.upcase}" } end assert_equal 'Foo is PROCESSING', machine.states[:processing].human_name(@model) end def test_should_use_default_translation_when_no_custom_human_name machine = StateMachines::Machine.new(@model, :status) do state :idle end # Should fall back to humanized version when no translation exists assert_equal 'idle', machine.states[:idle].human_name(@model) end def test_should_handle_nil_human_name machine = StateMachines::Machine.new(@model, :status) do state :waiting end # Explicitly set to nil (should still get default behavior) machine.states[:waiting].human_name = nil # When human_name is nil, State#human_name returns nil assert_nil machine.states[:waiting].human_name(@model) end def test_should_preserve_human_name_through_multiple_state_definitions machine = StateMachines::Machine.new(@model, :status) # First define state with custom human name machine.state :draft, human_name: 'Work in Progress' # Redefine the same state (this should not override the human_name) machine.state :draft do # Add some behavior end assert_equal 'Work in Progress', machine.states[:draft].human_name(@model) end def test_should_work_with_state_machine_helper_method @model.class_eval do state_machine :status, initial: :pending do state :pending, human_name: 'Awaiting Review' state :reviewed end end machine = @model.state_machine(:status) assert_equal 'Awaiting Review', machine.states[:pending].human_name(@model) end def test_should_handle_complex_i18n_lookup_with_custom_human_name # Set up complex I18n structure I18n.backend.store_translations(:en, { activemodel: { state_machines: { foo: { status: { states: { pending: 'Model Specific Pending' } } }, status: { states: { pending: 'Machine Specific Pending' } }, states: { pending: 'Generic Pending' } } } }) machine = StateMachines::Machine.new(@model, :status) do state :pending, human_name: 'Overridden Pending' end # Should use the custom human_name, not any of the I18n translations assert_equal 'Overridden Pending', machine.states[:pending].human_name(@model) end def teardown # Clear I18n translations after each test I18n.backend.reload! end end state_machines-activemodel-0.101.0/test/test_helper.rb000066400000000000000000000020201510204161300227370ustar00rootroot00000000000000# frozen_string_literal: true require 'debug' require 'state_machines-activemodel' require 'minitest/autorun' require 'minitest/reporters' require 'active_support/all' Minitest::Reporters.use! [Minitest::Reporters::ProgressReporter.new] I18n.enforce_available_locales = true class BaseTestCase < ActiveSupport::TestCase protected # Creates a plain model without ActiveModel features def new_plain_model(&block) model = Class.new do def self.name 'Foo' end end model.class_eval(&block) if block_given? model end # Creates a new ActiveModel model (and the associated table) def new_model(&block) model = Class.new do include ActiveModel::Model include ActiveModel::Attributes include ActiveModel::Dirty attribute :state, :string def self.name 'Foo' end def self.create new.tap { |instance| instance.save if instance.respond_to?(:save) } end end model.class_eval(&block) if block_given? model end end