565 lines
16 KiB
Ruby
565 lines
16 KiB
Ruby
module Shoulda
|
|
module Matchers
|
|
module ActiveRecord
|
|
# The `define_enum_for` matcher is used to test that the `enum` macro has
|
|
# been used to decorate an attribute with enum capabilities.
|
|
#
|
|
# class Process < ActiveRecord::Base
|
|
# enum status: [:running, :stopped, :suspended]
|
|
#
|
|
# alias_attribute :kind, :SomeLegacyField
|
|
#
|
|
# enum kind: [:foo, :bar]
|
|
# end
|
|
#
|
|
# # RSpec
|
|
# RSpec.describe Process, type: :model do
|
|
# it { should define_enum_for(:status) }
|
|
# it { should define_enum_for(:kind) }
|
|
# end
|
|
#
|
|
# # Minitest (Shoulda)
|
|
# class ProcessTest < ActiveSupport::TestCase
|
|
# should define_enum_for(:status)
|
|
# should define_enum_for(:kind)
|
|
# end
|
|
#
|
|
# #### Qualifiers
|
|
#
|
|
# ##### with_values
|
|
#
|
|
# Use `with_values` to test that the attribute can only receive a certain
|
|
# set of possible values.
|
|
#
|
|
# class Process < ActiveRecord::Base
|
|
# enum status: [:running, :stopped, :suspended]
|
|
# end
|
|
#
|
|
# # RSpec
|
|
# RSpec.describe Process, type: :model do
|
|
# it do
|
|
# should define_enum_for(:status).
|
|
# with_values([:running, :stopped, :suspended])
|
|
# end
|
|
# end
|
|
#
|
|
# # Minitest (Shoulda)
|
|
# class ProcessTest < ActiveSupport::TestCase
|
|
# should define_enum_for(:status).
|
|
# with_values([:running, :stopped, :suspended])
|
|
# end
|
|
#
|
|
# If the values backing your enum attribute are arbitrary instead of a
|
|
# series of integers starting from 0, pass a hash to `with_values` instead
|
|
# of an array:
|
|
#
|
|
# class Process < ActiveRecord::Base
|
|
# enum status: {
|
|
# running: 0,
|
|
# stopped: 1,
|
|
# suspended: 3,
|
|
# other: 99
|
|
# }
|
|
# end
|
|
#
|
|
# # RSpec
|
|
# RSpec.describe Process, type: :model do
|
|
# it do
|
|
# should define_enum_for(:status).
|
|
# with_values(running: 0, stopped: 1, suspended: 3, other: 99)
|
|
# end
|
|
# end
|
|
#
|
|
# # Minitest (Shoulda)
|
|
# class ProcessTest < ActiveSupport::TestCase
|
|
# should define_enum_for(:status).
|
|
# with_values(running: 0, stopped: 1, suspended: 3, other: 99)
|
|
# end
|
|
#
|
|
# ##### backed_by_column_of_type
|
|
#
|
|
# Use `backed_by_column_of_type` when the column backing your column type
|
|
# is a string instead of an integer:
|
|
#
|
|
# class LoanApplication < ActiveRecord::Base
|
|
# enum status: {
|
|
# active: "active",
|
|
# pending: "pending",
|
|
# rejected: "rejected"
|
|
# }
|
|
# end
|
|
#
|
|
# # RSpec
|
|
# RSpec.describe LoanApplication, type: :model do
|
|
# it do
|
|
# should define_enum_for(:status).
|
|
# with_values(
|
|
# active: "active",
|
|
# pending: "pending",
|
|
# rejected: "rejected"
|
|
# ).
|
|
# backed_by_column_of_type(:string)
|
|
# end
|
|
# end
|
|
#
|
|
# # Minitest (Shoulda)
|
|
# class LoanApplicationTest < ActiveSupport::TestCase
|
|
# should define_enum_for(:status).
|
|
# with_values(
|
|
# active: "active",
|
|
# pending: "pending",
|
|
# rejected: "rejected"
|
|
# ).
|
|
# backed_by_column_of_type(:string)
|
|
# end
|
|
#
|
|
## ##### with_prefix
|
|
#
|
|
# Use `with_prefix` to test that the enum is defined with a `_prefix`
|
|
# option (Rails 6+ only). Can take either a boolean or a symbol:
|
|
#
|
|
# class Issue < ActiveRecord::Base
|
|
# enum status: [:open, :closed], _prefix: :old
|
|
# end
|
|
#
|
|
# # RSpec
|
|
# RSpec.describe Issue, type: :model do
|
|
# it do
|
|
# should define_enum_for(:status).
|
|
# with_values([:open, :closed]).
|
|
# with_prefix(:old)
|
|
# end
|
|
# end
|
|
#
|
|
# # Minitest (Shoulda)
|
|
# class ProcessTest < ActiveSupport::TestCase
|
|
# should define_enum_for(:status).
|
|
# with_values([:open, :closed]).
|
|
# with_prefix(:old)
|
|
# end
|
|
#
|
|
# ##### with_suffix
|
|
#
|
|
# Use `with_suffix` to test that the enum is defined with a `_suffix`
|
|
# option (Rails 5 only). Can take either a boolean or a symbol:
|
|
#
|
|
# class Issue < ActiveRecord::Base
|
|
# enum status: [:open, :closed], _suffix: true
|
|
# end
|
|
#
|
|
# # RSpec
|
|
# RSpec.describe Issue, type: :model do
|
|
# it do
|
|
# should define_enum_for(:status).
|
|
# with_values([:open, :closed]).
|
|
# with_suffix
|
|
# end
|
|
# end
|
|
#
|
|
# # Minitest (Shoulda)
|
|
# class ProcessTest < ActiveSupport::TestCase
|
|
# should define_enum_for(:status).
|
|
# with_values([:open, :closed]).
|
|
# with_suffix
|
|
# end
|
|
#
|
|
# ##### without_scopes
|
|
#
|
|
# Use `without_scopes` to test that the enum is defined with
|
|
# '_scopes: false' option (Rails 5 only). Can take either a boolean or a
|
|
# symbol:
|
|
#
|
|
# class Issue < ActiveRecord::Base
|
|
# enum status: [:open, :closed], _scopes: false
|
|
# end
|
|
#
|
|
# # RSpec
|
|
# RSpec.describe Issue, type: :model do
|
|
# it do
|
|
# should define_enum_for(:status).
|
|
# without_scopes
|
|
# end
|
|
# end
|
|
#
|
|
# # Minitest (Shoulda)
|
|
# class ProcessTest < ActiveSupport::TestCase
|
|
# should define_enum_for(:status).
|
|
# without_scopes
|
|
# end
|
|
#
|
|
# @return [DefineEnumForMatcher]
|
|
#
|
|
def define_enum_for(attribute_name)
|
|
DefineEnumForMatcher.new(attribute_name)
|
|
end
|
|
|
|
# @private
|
|
class DefineEnumForMatcher
|
|
def initialize(attribute_name)
|
|
@attribute_name = attribute_name
|
|
@options = { expected_enum_values: [], scopes: true }
|
|
end
|
|
|
|
def description
|
|
description = "#{simple_description} backed by "
|
|
description << Shoulda::Matchers::Util.a_or_an(expected_column_type)
|
|
|
|
if expected_enum_values.any?
|
|
description << ' with values '
|
|
description << Shoulda::Matchers::Util.inspect_value(
|
|
expected_enum_values,
|
|
)
|
|
end
|
|
|
|
if options[:prefix]
|
|
description << ", prefix: #{options[:prefix].inspect}"
|
|
end
|
|
|
|
if options[:suffix]
|
|
description << ", suffix: #{options[:suffix].inspect}"
|
|
end
|
|
|
|
description
|
|
end
|
|
|
|
def with_values(expected_enum_values)
|
|
options[:expected_enum_values] = expected_enum_values
|
|
self
|
|
end
|
|
|
|
def with(expected_enum_values)
|
|
Shoulda::Matchers.warn_about_deprecated_method(
|
|
'The `with` qualifier on `define_enum_for`',
|
|
'`with_values`',
|
|
)
|
|
with_values(expected_enum_values)
|
|
end
|
|
|
|
def with_prefix(expected_prefix = true)
|
|
options[:prefix] = expected_prefix
|
|
self
|
|
end
|
|
|
|
def with_suffix(expected_suffix = true)
|
|
options[:suffix] = expected_suffix
|
|
self
|
|
end
|
|
|
|
def backed_by_column_of_type(expected_column_type)
|
|
options[:expected_column_type] = expected_column_type
|
|
self
|
|
end
|
|
|
|
def without_scopes
|
|
options[:scopes] = false
|
|
self
|
|
end
|
|
|
|
def matches?(subject)
|
|
@record = subject
|
|
|
|
enum_defined? &&
|
|
enum_values_match? &&
|
|
column_type_matches? &&
|
|
enum_value_methods_exist? &&
|
|
scope_presence_matches?
|
|
end
|
|
|
|
def failure_message
|
|
message =
|
|
if enum_defined?
|
|
"Expected #{model} to #{expectation}. "
|
|
else
|
|
"Expected #{model} to #{expectation}, but "
|
|
end
|
|
|
|
message << "#{failure_message_continuation}."
|
|
|
|
Shoulda::Matchers.word_wrap(message)
|
|
end
|
|
|
|
def failure_message_when_negated
|
|
message = "Expected #{model} not to #{expectation}, but it did."
|
|
Shoulda::Matchers.word_wrap(message)
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :attribute_name, :options, :record,
|
|
:failure_message_continuation
|
|
|
|
def expectation # rubocop:disable Metrics/MethodLength
|
|
if enum_defined?
|
|
expectation = "#{simple_description} backed by "
|
|
expectation << Shoulda::Matchers::Util.a_or_an(expected_column_type)
|
|
|
|
if expected_enum_values.any?
|
|
expectation << ', mapping '
|
|
expectation << presented_enum_mapping(
|
|
normalized_expected_enum_values,
|
|
)
|
|
end
|
|
|
|
if expected_prefix
|
|
expectation <<
|
|
if expected_suffix
|
|
', '
|
|
else
|
|
' and '
|
|
end
|
|
|
|
expectation << 'prefixing accessor methods with '
|
|
expectation << "#{expected_prefix}_".inspect
|
|
end
|
|
|
|
if expected_suffix
|
|
expectation <<
|
|
if expected_prefix
|
|
', and '
|
|
else
|
|
' and '
|
|
end
|
|
|
|
expectation << 'suffixing accessor methods with '
|
|
expectation << "_#{expected_suffix}".inspect
|
|
end
|
|
|
|
if exclude_scopes?
|
|
expectation << ' with no scopes'
|
|
end
|
|
|
|
expectation
|
|
else
|
|
simple_description
|
|
end
|
|
end
|
|
|
|
def simple_description
|
|
"define :#{attribute_name} as an enum"
|
|
end
|
|
|
|
def presented_enum_mapping(enum_values)
|
|
enum_values.
|
|
map { |output_to_input|
|
|
output_to_input.
|
|
map(&Shoulda::Matchers::Util.method(:inspect_value)).
|
|
join(' to ')
|
|
}.
|
|
to_sentence
|
|
end
|
|
|
|
def normalized_expected_enum_values
|
|
to_hash(expected_enum_values)
|
|
end
|
|
|
|
def expected_enum_value_names
|
|
to_array(expected_enum_values)
|
|
end
|
|
|
|
def expected_enum_values
|
|
options[:expected_enum_values]
|
|
end
|
|
|
|
def normalized_actual_enum_values
|
|
to_hash(actual_enum_values)
|
|
end
|
|
|
|
def actual_enum_values
|
|
model.send(attribute_name.to_s.pluralize)
|
|
end
|
|
|
|
def enum_defined?
|
|
if model.defined_enums.include?(attribute_name.to_s)
|
|
true
|
|
else
|
|
@failure_message_continuation =
|
|
"no such enum exists on #{model}"
|
|
false
|
|
end
|
|
end
|
|
|
|
def enum_values_match?
|
|
passed =
|
|
expected_enum_values.empty? ||
|
|
normalized_actual_enum_values == normalized_expected_enum_values
|
|
|
|
if passed
|
|
true
|
|
else
|
|
@failure_message_continuation =
|
|
"However, #{attribute_name.inspect} actually maps " +
|
|
presented_enum_mapping(normalized_actual_enum_values)
|
|
false
|
|
end
|
|
end
|
|
|
|
def column_type_matches?
|
|
if column.type == expected_column_type.to_sym
|
|
true
|
|
else
|
|
@failure_message_continuation =
|
|
"However, #{attribute_name.inspect} is "\
|
|
"#{Shoulda::Matchers::Util.a_or_an(column.type)}"\
|
|
' column'
|
|
false
|
|
end
|
|
end
|
|
|
|
def expected_column_type
|
|
options[:expected_column_type] || :integer
|
|
end
|
|
|
|
def column
|
|
key = attribute_name.to_s
|
|
column_name = model.attribute_alias(key) || key
|
|
|
|
model.columns_hash[column_name]
|
|
end
|
|
|
|
def model
|
|
record.class
|
|
end
|
|
|
|
def enum_value_methods_exist?
|
|
if instance_methods_exist?
|
|
true
|
|
else
|
|
message = missing_methods_message
|
|
|
|
message << " (we can't tell which)"
|
|
|
|
@failure_message_continuation = message
|
|
|
|
false
|
|
end
|
|
end
|
|
|
|
def scope_presence_matches?
|
|
if exclude_scopes?
|
|
if singleton_methods_exist?
|
|
message = "#{attribute_name.inspect} does map to these values "
|
|
message << 'but class scope methods were present'
|
|
|
|
@failure_message_continuation = message
|
|
|
|
false
|
|
else
|
|
true
|
|
end
|
|
elsif singleton_methods_exist?
|
|
true
|
|
else
|
|
if enum_defined?
|
|
message = 'But the class scope methods are not present'
|
|
else
|
|
message = missing_methods_message
|
|
|
|
message << 'or the class scope methods are not present'
|
|
message << " (we can't tell which)"
|
|
end
|
|
|
|
@failure_message_continuation = message
|
|
|
|
false
|
|
end
|
|
end
|
|
|
|
def missing_methods_message
|
|
message = "#{attribute_name.inspect} does map to these "
|
|
message << 'values, but the enum is '
|
|
|
|
if expected_prefix
|
|
if expected_suffix
|
|
message << 'configured with either a different prefix or '
|
|
message << 'suffix, or no prefix or suffix at all'
|
|
else
|
|
message << 'configured with either a different prefix or no '
|
|
message << 'prefix at all'
|
|
end
|
|
elsif expected_suffix
|
|
message << 'configured with either a different suffix or no '
|
|
message << 'suffix at all'
|
|
else
|
|
''
|
|
end
|
|
end
|
|
|
|
def singleton_methods_exist?
|
|
expected_singleton_methods.all? do |method|
|
|
model.singleton_methods.include?(method)
|
|
end
|
|
end
|
|
|
|
def instance_methods_exist?
|
|
expected_instance_methods.all? do |method|
|
|
record.methods.include?(method)
|
|
end
|
|
end
|
|
|
|
def expected_singleton_methods
|
|
expected_enum_value_names.map do |name|
|
|
[expected_prefix, name, expected_suffix].
|
|
select(&:present?).
|
|
join('_').
|
|
to_sym
|
|
end
|
|
end
|
|
|
|
def expected_instance_methods
|
|
methods = expected_enum_value_names.map do |name|
|
|
[expected_prefix, name, expected_suffix].
|
|
select(&:present?).
|
|
join('_')
|
|
end
|
|
|
|
methods.flat_map do |m|
|
|
["#{m}?".to_sym, "#{m}!".to_sym]
|
|
end
|
|
end
|
|
|
|
def expected_prefix
|
|
if options.include?(:prefix)
|
|
if options[:prefix] == true
|
|
attribute_name
|
|
else
|
|
options[:prefix]
|
|
end
|
|
end
|
|
end
|
|
|
|
def expected_suffix
|
|
if options.include?(:suffix)
|
|
if options[:suffix] == true
|
|
attribute_name
|
|
else
|
|
options[:suffix]
|
|
end
|
|
end
|
|
end
|
|
|
|
def exclude_scopes?
|
|
!options[:scopes]
|
|
end
|
|
|
|
def to_hash(value)
|
|
if value.is_a?(Array)
|
|
value.each_with_index.inject({}) do |hash, (item, index)|
|
|
hash.merge(item.to_s => index)
|
|
end
|
|
else
|
|
value.stringify_keys
|
|
end
|
|
end
|
|
|
|
def to_array(value)
|
|
if value.is_a?(Array)
|
|
value.map(&:to_s)
|
|
else
|
|
value.keys.map(&:to_s)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|